{"generated":"2026-11-12T10:18:09.344Z","siteUrl":"https://docx.payability.com","components":[{"name":"actionstools-app","title":"Actions Tools App","description":"What the Actions Tools App does, how it is put together, and where to read next.","url":"https://docx.payability.com/actionstools-app/index.html","pageCount":31},{"name":"automatic-uw-tool","title":"Automatic Underwriting Tool","description":"What the Automatic Underwriting Tool does, how it is put together, and where to read next.","url":"https://docx.payability.com/automatic-uw-tool/index.html","pageCount":41},{"name":"dialer-v2","title":"Dialer V2","description":"AI-powered outbound campaign management platform — architecture, business logic, data model, APIs, events and operations.","url":"https://docx.payability.com/dialer-v2/index.html","pageCount":82},{"name":"eventlogger-rules","title":"EventLogger Rules","description":"Event rule engine for the Payability platform: matches EventLogger events against configurable rules and runs their actions.","url":"https://docx.payability.com/eventlogger-rules/index.html","pageCount":14},{"name":"internal-admin","title":"Internal Admin","description":"The Payability internal administration client — architecture, business logic and operations.","url":"https://docx.payability.com/internal-admin/index.html","pageCount":59},{"name":"morpheus","title":"Morpheus","description":"Automated outbound sales platform for Amazon and Walmart marketplace sellers, built on AWS.","url":"https://docx.payability.com/morpheus/index.html","pageCount":85},{"name":"oppa","title":"OPPA","description":"What OPPA does and where to read next.","url":"https://docx.payability.com/oppa/index.html","pageCount":4},{"name":"pyb-docs","title":"Payability Documentation","description":"Architecture, business rules, integrations and operating guides for every Payability system, in one place.","url":"https://docx.payability.com/pyb-docs/index.html","pageCount":3},{"name":"supplier-client","title":"Supplier Client","description":"Developer reference for the Payability Supplier Client — an Angular SPA for supplier onboarding, financial products, reporting and account management.","url":"https://docx.payability.com/supplier-client/index.html","pageCount":67}],"pages":[{"component":"actionstools-app","module":"ROOT","path":"index.adoc","title":"Actions Tools App","description":"What the Actions Tools App does, how it is put together, and where to read next.","url":"https://docx.payability.com/actionstools-app/index.html","navOrder":0,"source":"= Actions Tools App\n:navtitle: Overview\n:description: What the Actions Tools App does, how it is put together, and where to read next.\n\n{project-name} is the backend for the CRM UI components used by the internal admin (IA) and\nservice desk: *notes*, *file attachments*, *tasks*, *comments*, *notifications*, the supplier\n*activity stream* and the *global settings* (task statuses, email templates, folders). The UI\ncomponents own the presentation; this service owns storage, permissions, notifications and the\ncounters behind the task grid.\n\n== The 30-second model\n\n* *Two Lambda functions, one code base.* `ApiFunction` serves HTTP through API Gateway;\n  `WorkerFunction` runs background work. Both are defined in `template.yaml` and deployed by\n  AWS SAM as one CloudFormation stack per environment (`{prod-stack}`, `{stage-stack}`).\n* *Flask + flask-restx.* `run.py` builds the Flask app, `lambda_handlers.py` adapts it to Lambda\n  with apig-wsgi. Most endpoints are flask-restx namespaces (they show up in Swagger); a few\n  are plain Flask blueprints.\n* *DynamoDB for everything.* Eight `em_actions_*` tables, accessed through flask-dynamo and the\n  CRUD services of the internal `pyb_flask_utils` library.\n* *Background work is a Lambda invoke.* Functions decorated with `@async_task` are sent to the\n  Worker as an asynchronous invocation. The Worker also consumes the tasks table stream (to keep\n  grid counters current) and a 4-day cleanup schedule.\n* *Secrets live in Secrets Manager.* `secrets_loader.py` copies the `actionstools/<env>` secret\n  into `os.environ` at cold start, before any module reads configuration.\n\n[source]\n----\n  CRM UI ──X-Session-Token──► API Gateway (REST, CORS) ──► ApiFunction (Flask)\n                                                              │ DynamoDB em_actions_*\n                                                              │ Payability internal API (permissions, users)\n                                                              │ Event logger / Messaging / B2B documents\n                                                              │\n                                              lambda:Invoke (Event)\n                                                              ▼\n  em_actions_tasks stream ─────────────────────────►  WorkerFunction\n  EventBridge rate(4 days) ────────────────────────►    │ Slack DMs, notification emails, event log entries\n                                                        │ task counters (em_actions_tasks_counters)\n----\n\n== Where to read next\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| If you want… | Read\n\n| The big picture: functions, resources, layers\n| xref:architecture.adoc[]\n\n| How one request flows end to end\n| xref:request-lifecycle.adoc[]\n\n| Every endpoint, its headers and payloads\n| xref:api/index.adoc[]\n\n| What runs in the background and why\n| xref:async/worker.adoc[] · xref:async/counters.adoc[]\n\n| Table names, keys and indexes\n| xref:data/dynamodb.adoc[]\n\n| The services this one calls\n| xref:components/event-logger.adoc[] and the other Integrations pages\n\n| Running it locally, configuring it, shipping it\n| xref:operations/local-development.adoc[] · xref:operations/configuration.adoc[] · xref:operations/deployment.adoc[]\n\n| Moving production off Zappa\n| xref:operations/zappa-cutover.adoc[]\n\n| A term you do not recognise\n| xref:reference/glossary.adoc[]\n|===\n\n== Repository layout\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Path | Contents\n\n| `lambda_handlers.py` | Lambda entry points: `api_handler` and `worker_handler`.\n| `run.py` | Builds the Flask app (`create_app(\"config\")`) and initialises Sentry.\n| `config.py` | Flask config object; reads every setting from `os.environ`.\n| `secrets_loader.py` | Loads the Secrets Manager secret into `os.environ`.\n| `actionstools_app/` | Application package, one sub-package per resource — see xref:reference/source-map.adoc[].\n| `tests/` | unittest suite, offline (moto for DynamoDB).\n| `template.yaml`, `samconfig.toml` | SAM template and per-environment deploy config.\n| `pyproject.toml`, `uv.lock`, `Makefile` | uv project and developer commands.\n| `.github/workflows/` | GitHub Actions: tests on pull requests, deploys from `develop` (stage) / `release` (prod).\n| `scripts/`, `examples/` | One-off maintenance scripts and API usage examples.\n| `docs/` | This Antora documentation component.\n|===\n\n== Tech stack\n\nPython {python-version} · Flask 3 · flask-restx · apig-wsgi · flask-dynamo · boto3 ·\n`pyb_flask_utils`, `eventlogger`, `messaging` (internal SDKs) · slack_sdk · Sentry ·\nAWS (Lambda, API Gateway, DynamoDB, Secrets Manager, EventBridge, CloudFormation/SAM) ·\nGitHub Actions · uv.\n"},{"component":"actionstools-app","module":"ROOT","path":"architecture.adoc","title":"System architecture","description":"The AWS resources in the stack, the code layers, and the rules that hold them together.","url":"https://docx.payability.com/actionstools-app/architecture.html","navOrder":1,"source":"= System architecture\n:navtitle: System architecture\n:description: The AWS resources in the stack, the code layers, and the rules that hold them together.\n\n== AWS resources\n\nEverything is declared in `template.yaml` and deployed as one stack per environment.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Logical id | Physical name | Role\n\n| `ActionsToolsApi`\n| `actionstools-<env>` (REST API)\n| API Gateway REST API. Two `ANY` routes (`/` and `/{proxy+}`) proxy everything to `ApiFunction`.\n  CORS is answered by API Gateway itself, including `Access-Control-Max-Age: 10800`. Every route\n  except the CORS `OPTIONS` methods requires the `PayabilityAPIGatewayAuthorizer` Lambda request\n  authorizer (`pyb-api-gateway-authorizer:live` in the same account, identity source `Host`,\n  cached 3600 s).\n\n| `ApiFunction`\n| `actionstools-<env>-api`\n| Handler `lambda_handlers.api_handler`. Serves HTTP.\n\n| `WorkerFunction`\n| `actionstools-<env>-worker`\n| Handler `lambda_handlers.worker_handler`. Async tasks, the tasks stream, the cleanup schedule.\n\n| `WorkerFunction` → `TasksStream`\n| event source mapping\n| Reads the `em_actions_tasks` stream (`LATEST`, batch 50, 3 retries, bisect on error).\n\n| `WorkerFunction` → `RemoveCountersSchedule`\n| EventBridge rule\n| `rate(4 days)` with input `{\"action\": \"remove_counters\"}`. Production only.\n\n| `ApiFunction` → `KeepWarm`\n| EventBridge rule\n| `rate(4 minutes)` with input `{\"action\": \"keep_warm\"}`, which `api_handler` answers without\n  touching Flask. Keeps one execution environment warm, like Zappa's `keep_warm`. Production only.\n|===\n\nBoth functions share the `Globals` block: `python{python-version}`, 512 MB, 120 s timeout, the\nsame environment variables, and the VPC subnets and security group for that environment\n(needed to reach the internal API; stage uses the dev VPC shared with Messaging and Documents).\nEach function gets its own IAM\nrole generated from SAM policy templates: CRUD on the eight `em_actions_*` tables, read on the\nreports cache table (API only), `GetSecretValue` on its own secret and on `common/crm`, and\n`lambda:InvokeFunction` on the Worker.\n\nNOTE: The DynamoDB tables are *not* part of the stack. They predate it, hold production data, and\nare referenced by name. Deleting the stack never deletes data.\n\n== Why two functions\n\nThe split mirrors what Zappa ran as `prod` and `prod2`, without the second deployment:\n\n* The *API* must answer fast and scale with traffic.\n* *Background work* (Slack, emails, event log writes, counter recalculation) must not hold the\n  HTTP response, and the *stream* consumer must not compete with API concurrency.\n\nBoth functions are built from the same artifact and updated by the same deploy. For the few\nseconds while CloudFormation updates them one after the other, a task queued by the old API\ncode can run on the new Worker code (or vice versa), so keep task signatures\nbackward compatible across one release.\n\n== Code layers\n\n[source]\n----\nlambda_handlers.py           Lambda entry points (api_handler, worker_handler)\nrun.py                       app = create_app(\"config\"); setup_sentry()\nactionstools_app/__init__.py Flask app, CORS, flask-dynamo, table definitions, create_app()\nactionstools_app/api.py      flask-restx Api, ApiResource base classes, shared header parsers\nactionstools_app/<resource>/ views.py (HTTP) → services.py (DynamoDB, rules) → models.py (schemas)\nactionstools_app/tools/      async_task, error handling, permissions helpers, Sentry setup\nconfig.py                    every setting, from os.environ\nsecrets_loader.py            Secrets Manager → os.environ (imported first)\n----\n\n`<resource>` is one of `notes`, `tasks`, `comments`, `files`, `settings`, `notifications`,\n`activity`, `reports`; `ddb_counts` holds the counter machinery. Services extend the CRUD\nclasses from `pyb_flask_utils.rest.services`, which implement soft delete, timestamps,\npagination and owner permission checks.\n\n== Rules that hold it together\n\n* *Import order matters.* `secrets_loader` must run before anything imports `pyb_flask_utils`\n  or `config`, because both read `os.environ` at import time. It is the first import in\n  `config.py` and in `actionstools_app/__init__.py`. See xref:operations/configuration.adoc[].\n* *Task arguments are JSON.* Anything passed to an `@async_task` function goes through\n  `simplejson` into a Lambda payload (max 256 KB). Decimals from DynamoDB are converted first\n  (see `parse_decimal_to_int` in `tasks/services.py`).\n* *Only `actionstools_app.*` functions decorated with `@async_task` can be run by the Worker.*\n  `worker_handler` rejects anything else.\n* *`BASE_PREFIX` is `/actionstools` in production and empty elsewhere*, matching the custom\n  domain path. See xref:request-lifecycle.adoc#paths[].\n"},{"component":"actionstools-app","module":"ROOT","path":"request-lifecycle.adoc","title":"Request lifecycle","description":"What happens between a browser request and the JSON response, from cold start to async side effects.","url":"https://docx.payability.com/actionstools-app/request-lifecycle.html","navOrder":2,"source":"= Request lifecycle\n:navtitle: Request lifecycle\n:description: What happens between a browser request and the JSON response, from cold start to async side effects.\n\n== Cold start\n\nOn the first request a new Lambda execution environment imports `lambda_handlers`:\n\n. `config` is imported, whose first line imports `secrets_loader`. If `APP_SECRET_ID` is set and\n  `AWS_SECRETS_MANAGER` is not `\"False\"`, the JSON secret is fetched and each key is copied into\n  `os.environ` with `setdefault` (an explicit environment variable wins).\n. `run.py` calls `create_app(\"config\")`: flask-restx namespaces and blueprints are registered under\n  `BASE_PREFIX`.\n. Importing `pyb_flask_utils.auth` loads the shared `common/crm` secret (instance-id tokens and,\n  if not already in the environment, `AUTH_JWT_RSA_PUBLIC_KEY`).\n. `setup_sentry()` initialises Sentry outside `dev`/`local`/`test`.\n\nA cold start therefore costs two `GetSecretValue` calls. Warm invocations reuse everything.\n\n[#paths]\n== Paths and stages\n\nAPI Gateway passes the full request path to Lambda in `event[\"path\"]`, and apig-wsgi uses it as\n`PATH_INFO`:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Request | `event[\"path\"]` | Matches\n\n| `{prod-url}/notes/` (custom domain, base path `actionstools`)\n| `/actionstools/notes/`\n| `BASE_PREFIX + \"/notes/\"` in production\n\n| `https://<api-id>.execute-api.us-east-1.amazonaws.com/prod/actionstools/notes/`\n| `/actionstools/notes/`\n| same\n\n| `https://<api-id>.execute-api.us-east-1.amazonaws.com/stage/notes/`\n| `/notes/`\n| `BASE_PREFIX` is empty outside production\n|===\n\nThe stage name is never part of `event[\"path\"]`; the custom-domain base path is. That is why\nproduction keeps `BASE_PREFIX = \"/actionstools\"` (`config.py`).\n\n== Request\n\n. *CORS preflight.* `OPTIONS` is answered by API Gateway (mock integration from the `Cors` block\n  in `template.yaml`), never reaching Lambda.\n. *Routing.* `/{proxy+}` → `ApiFunction` → `apig_wsgi` → Flask.\n. *Authentication.* Every resource uses one of the decorators from `pyb_flask_utils.auth` — see\n  xref:api/index.adoc#authentication[]. On success the view receives `login_id` (email) and\n  `login_key` (user uuid).\n. *Header context.* `Helper(HEADERS).parse_headers` maps `X-Actions-*` and instance headers to\n  `target_uuid`, `target_type`, `owner_uuid`, `instance_id` (`config.HEADERS`).\n. *Service call.* The view calls a service in the same package, which reads or writes DynamoDB\n  and, for owner-only fields, asks the Payability internal API whether the user is the owner or a\n  `CRM_ADMIN` super user.\n. *Response.* `prepare_json_response` wraps everything in the `{data, meta, status_code}`\n  envelope. Exceptions are converted by `handle_errors` / `PybApi.handle_error`.\n\n== Side effects\n\nAnything slow or best-effort is queued with `@async_task`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Trigger | Async task\n\n| Task created (sync mode) | `send_notification_to_new_assignee` → emails/Slack to the assignee and followers\n| Task created with `\"_mode\": \"async\"` | `async_create_task` — the whole creation runs on the Worker\n| Task updated by a logged-in user | `send_events_and_notifications` → event logger entries plus notifications\n| Comment on a task | `do_notify` → owner, assignee and followers\n| Each Slack DM | `notify_on_slack`\n| First request for an unknown counter | `async_calculate_count`\n|===\n\nThe API returns as soon as the `lambda:Invoke` (type `Event`) is accepted. See\nxref:async/worker.adoc[] for what happens next and how failures surface.\n"},{"component":"actionstools-app","module":"ROOT","path":"api/index.adoc","title":"API overview","description":"Authentication, context headers, the response envelope, pagination and the full route catalog.","url":"https://docx.payability.com/actionstools-app/api/index.html","navOrder":3,"source":"= API overview\n:navtitle: API overview\n:description: Authentication, context headers, the response envelope, pagination and the full route catalog.\n\nBase URLs: `{prod-url}` in production; the `ApiUrl` output of the `{stage-stack}` stack on\nstage. Swagger UI for the flask-restx namespaces is served at the base URL. All paths below\nare relative to it and *end with a trailing slash* unless shown otherwise.\n\n[#authentication]\n== Authentication\n\nTwo schemes, both implemented in `pyb_flask_utils.auth`:\n\n[cols=\"2,3,3\",options=\"header\"]\n|===\n| Decorator | Accepts | Used by\n\n| `requires_auth_jwt_rsa`\n| `X-Session-Token: <JWT>`, validated by the Payability internal API\n  (`GET \\{PAYABILITY_INTERNAL_API_URL}token/validate`, which returns `login_id`); `login_key` is read\n  from the token's payload. `AUTH_JWT_RSA_PUBLIC_KEY` must be set but is not used to verify it.\n| Every `ApiResource` (all namespaces) and every blueprint route.\n\n| `require_base_jwt_rsa_auth_or_instance_id`\n| The JWT above, *or* `Authorization: Token <token>` plus `X-EventLogger-InstanceId` / `X-InstanceId`, where the instance-id → token pair is in the `common/crm` secret.\n| `ApiResourceWithAPiKeys`: only `/settings/<config_key>/`.\n|===\n\nA valid JWT gives the view `login_id` (email) and `login_key` (user uuid). Authentication\nfailures return *400*, not 401, on purpose: a 401 logs IA and service-desk users out.\n\n[#context-headers]\n== Context headers\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Header | Maps to | Meaning\n\n| `X-Actions-TargetUUID` | `target_uuid` | The entity the item belongs to — usually a supplier key, or a task/note uuid for comments.\n| `X-Actions-TargetType` | `target_type` | Kind of target, e.g. `supplier`, `suppliers-payment`, `note`, `task`.\n| `X-Actions-OwnerUUID` | `owner_uuid` | Overridden by the JWT's `login_key` when present.\n| `X-Actions-Type` | `type` | Item sub-type.\n| `X-EventLogger-InstanceId` / `X-InstanceId` | `instance_id` (int) | SaaS instance; Payability is `8001`.\n|===\n\nDefined in `config.HEADERS`; parsed by `pyb_flask_utils.rest.common.Helper`.\n\n[#envelope]\n== Response envelope\n\nEvery JSON response has the same shape (`pyb_flask_utils.rest.response.prepare_json_response`):\n\n[source,json]\n----\ninclude::example$response-single.json[]\n----\n\nCollections put a list in `data` and pagination in `meta`:\n\n[source,json]\n----\ninclude::example$response-collection.json[]\n----\n\nErrors keep the envelope; `meta.errors` lists `{type, message}` and `status_code` repeats the\nHTTP status:\n\n[source,json]\n----\ninclude::example$response-error.json[]\n----\n\nCommon error types: `ResourceNotFound` (404), `Unauthorized` (400), `Unknown` (500),\n`DataValidationException` / `BadRequest` (400), `AccessDenied` (403).\n\n[#pagination]\n== Pagination, filtering and ordering\n\n* `?limit=<n>` — page size (DynamoDB `Limit`, so a filtered page may hold fewer items).\n* `?last=<json>` — pass `meta.last` from the previous page verbatim (URL-encoded JSON).\n  No `meta.last` means no further pages.\n* `?q={\"filters\":[{\"name\":\"status\",\"op\":\"in\",\"val\":[\"new\",\"in-progr\"]}]}` — filters on list\n  endpoints that support them (tasks, settings). Operators: `eq`, `ne`, `in`, `lt`, `gt` (and the\n  boto3 condition operators supported by `FilterHelper`).\n* `?order_by={\"field\":\"created_at\",\"direction\":\"desc\"}` — picks a matching index where one exists.\n\n== Route catalog\n\n[cols=\"1,3,2\",options=\"header\"]\n|===\n| Method | Path | Page\n\n| GET, POST | `/notes/` | xref:api/notes.adoc[]\n| GET, PUT, PATCH, DELETE | `/notes/<uuid>/` | xref:api/notes.adoc[]\n| GET | `/notes/pinned` | xref:api/notes.adoc[]\n| POST | `/notes/pinned/batch` | xref:api/notes.adoc[]\n| POST | `/notes/bulk-update/` | xref:api/notes.adoc[]\n| GET, POST, PATCH, DELETE | `/tasks/` | xref:api/tasks.adoc[]\n| GET, PUT, DELETE | `/tasks/<uuid>/` | xref:api/tasks.adoc[]\n| POST | `/tasks/filters/counts/` | xref:api/tasks.adoc[]\n| POST | `/tasks/filters/counts/assignees/` | xref:api/tasks.adoc[]\n| GET | `/tasks/<uuid>/events/` | xref:api/tasks.adoc[]\n| GET, POST | `/comments/` | xref:api/comments.adoc[]\n| PATCH, DELETE | `/comments/<uuid>/<target_uuid>/` | xref:api/comments.adoc[]\n| GET, POST | `/files/` | xref:api/files.adoc[]\n| GET | `/files/<uuid>/` | xref:api/files.adoc[]\n| DELETE | `/files/<uuid>/<b2bdoc_uuid>/` | xref:api/files.adoc[]\n| GET, POST | `/settings/` | xref:api/settings.adoc[]\n| GET, PUT, DELETE | `/settings/<config_key>/` | xref:api/settings.adoc[]\n| GET | `/settings/<config_type>/items` | xref:api/settings.adoc[]\n| GET, POST | `/folders/` | xref:api/settings.adoc[]\n| DELETE | `/folders/<folder_name>/` | xref:api/settings.adoc[]\n| GET, POST, PATCH | `/notifications/` | xref:api/notifications.adoc[]\n| PATCH | `/notifications/<uuid>/` | xref:api/notifications.adoc[]\n| GET | `/activity/` | xref:api/activity.adoc[]\n| GET | `/reports/es_cache` | xref:api/reports.adoc[]\n| GET | `/setup-install` | Creates missing tables from `dynamo_tables` (`run_install`). Unauthenticated; development only.\n|===\n\nIn production `/files/` and `/settings/` are also registered *without* the `/actionstools`\nprefix (\"temporary path workaround\" in `create_app`).\n\n[NOTE]\n====\n`examples/notes_api.py` is a runnable Python walkthrough of the notes endpoints.\n====\n"},{"component":"actionstools-app","module":"ROOT","path":"api/notes.adoc","title":"Notes","description":"Notes attached to a supplier (or any target): create, list, pin, edit, delete and bulk operations.","url":"https://docx.payability.com/actionstools-app/api/notes.html","navOrder":4,"source":"= Notes\n:navtitle: Notes\n:description: Notes attached to a supplier (or any target): create, list, pin, edit, delete and bulk operations.\n\nNamespace `notes` (`actionstools_app/notes/`), table `em_actions_notes2` in production\n(`em_actions_notes_staging` elsewhere), key `(target_uuid, uuid)`. All routes require a JWT\n(xref:api/index.adoc#authentication[]). Notes use `AllowEverythingValidator`, so any\nauthenticated user may edit any note.\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/notes/`\n| Notes for `X-Actions-TargetUUID`, newest update first (`target_uuid-updated_at-index`). Each item\n  carries `latest_comments` (1) and `comments_count`. `meta.count` is the total for the target.\n\n| POST | `/notes/`\n| Create. Body `{\"body\": \"<html>\", \"user\": {...}, \"is_pinned\": false}`; headers\n  `X-Actions-TargetUUID` and `X-Actions-TargetType` (default `note`) are required. `owner_uuid` is the\n  JWT's `login_key`.\n\n| GET | `/notes/<uuid>/`\n| Single note, looked up by `X-InstanceId` + `uuid` (`instance_id-uuid-index`), because the primary key\n  needs `target_uuid`.\n\n| PUT | `/notes/<uuid>/`\n| Replace body and top-level fields. Needs `X-Actions-TargetUUID`.\n\n| PATCH | `/notes/<uuid>/`\n| Change top-level attributes only (`is_pinned`, `target_type`, `owner_uuid`, `target_uuid`). Any other\n  key is a 400. Pinning alone also stamps `is_pinned_updated_at`.\n\n| DELETE | `/notes/<uuid>/`\n| Soft delete (sets `deleted_at`).\n\n| GET | `/notes/pinned`\n| Pinned notes for the target (`target_uuid-is_pinned-index`), sorted by `is_pinned_updated_at`\n  (falling back to `created_at`) descending. Page size is forced to at least 500.\n\n| POST | `/notes/pinned/batch`\n| `{\"keys\": [\"<supplier key>\", ...]}` → `{\"<key>\": [up to 5 pinned notes]}`.\n\n| POST | `/notes/bulk-update/`\n| A list of notes: items with `uuid` are updated, items without are created. Stops at the first failure\n  and returns `{\"updated\": [...], \"created\": [...]}` with a 500.\n|===\n\n`is_pinned` is stored as `0`/`1` and returned as a boolean (`translate_pinned`). Every returned\nitem gets `\"type\": \"note\"`, which the activity stream relies on.\n\n.Create a note\n[source,json]\n----\ninclude::example$note-create-request.json[]\n----\n"},{"component":"actionstools-app","module":"ROOT","path":"api/tasks.adoc","title":"Tasks","description":"CRM tasks: the grid, per-supplier lists, creation (sync or async), updates, deletes, counts and history.","url":"https://docx.payability.com/actionstools-app/api/tasks.html","navOrder":5,"source":"= Tasks\n:navtitle: Tasks\n:description: CRM tasks: the grid, per-supplier lists, creation (sync or async), updates, deletes, counts and history.\n\nNamespace `tasks` plus the `tasks` blueprint (`actionstools_app/tasks/`), table\n`em_actions_tasks` (`_staging` outside production), key `(instance_id, uuid)`. All routes require a\nJWT.\n\n== Routes\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/tasks/`\n| With `X-Actions-TargetUUID`: tasks of that target, newest update first, each with\n  `latest_comments` and `comments_count`. Without it: the task grid, filtered by `?q=` / `?order_by=`\n  (xref:api/index.adoc#pagination[]), defaulting to all tasks of instance `8001`. `meta.count`\n  comes from the counters (xref:async/counters.adoc[]).\n\n| POST | `/tasks/`\n| Create. Required body fields: `created_by_name`, `title`, `assignee_name`, `assignee_uuid`,\n  `assignee_type` (`user`/`group`), `priority`, `status`, plus `instance_id` (header). See\n  <<create>>.\n\n| PATCH | `/tasks/`\n| Bulk update: `{\"<uuid>\": {fields...}, ...}`. Each update goes through `perform_update`.\n\n| DELETE | `/tasks/`\n| Bulk soft delete: `{\"uuids\": [...]}`. Returns 404 listing the uuids that do not exist, without\n  deleting anything.\n\n| GET | `/tasks/<uuid>/`\n| Single task (`X-InstanceId` required).\n\n| PUT | `/tasks/<uuid>/`\n| Update (same required fields as create).\n\n| DELETE | `/tasks/<uuid>/`\n| Soft delete.\n\n| POST | `/tasks/filters/counts/`\n| `{\"queries\": [{\"filters_query\": [...filters]}, ...]}` → the same list with `count` set on each.\n\n| POST | `/tasks/filters/counts/assignees/`\n| `{\"assignees\": [...], \"filters_query\": [...]}` → `{\"<assignee_uuid>\": count}`.\n\n| GET | `/tasks/<uuid>/events/`\n| Change history of a task, proxied from the event logger's `/es-changelog`.\n|===\n\n[#create]\n== Creating a task\n\n[source,json]\n----\ninclude::example$task-create-request.json[]\n----\n\n* `item_number` is assigned from the global counter `em_actions_counters` (atomic increment).\n* `dueDate` may be a timestamp or any date string `dateutil` parses; it is stored as a Unix\n  timestamp. `due_date` and `dueDate` are kept in sync (`sanitize_task_for_iav2`).\n* `priority_int` is derived from `priority` (`low` 10 … `critical` 40) for sorting.\n* `task_url_template` (URL-encoded, `{}` replaced by the task uuid) is used in notifications.\n  Production defaults to `https://internal.payability.com/#/tasks?taskUuid={}`.\n* *Duplicate guard:* if one of the last three tasks created on the instance has the same body,\n  title, target, assignee, owner, status, type and priority, that task is returned instead of a new\n  one. This absorbs double clicks and client retries.\n\n`\"_mode\": \"async\"` in the body queues the whole creation on the Worker and returns immediately\nwith `meta.message = \"object has been successfully queued to create\"` and no `data`.\n\n[WARNING]\n====\nAsync mode calls `get_auth_data()` on the Worker, where there is no Flask request, so the\nassignee notification step fails there (logged to Sentry). The task itself is created first.\nThis predates the SAM migration; prefer the default sync mode until it is fixed.\n====\n\n== Updates and notifications\n\n`perform_update` diffs the new fields against the stored task:\n\n* Changing `item_number`, `instance_id`, `uuid`, `created_at`, `created_by_name`, `owner_uuid`, `type`,\n  `target_type`, `body` or `title` requires the caller to be the owner or a `CRM_ADMIN`\n  (checked against the Payability internal API).\n* Setting `status` to `done` reassigns the task to its creator.\n* If the caller is logged in, `send_events_and_notifications` is queued: two event-logger entries\n  (task and supplier) and, when `task_url_template` was sent, notifications to the assignee, the\n  owner and followers. See xref:components/slack.adoc[] and xref:components/messaging.adoc[].\n"},{"component":"actionstools-app","module":"ROOT","path":"api/comments.adoc","title":"Comments","description":"Threaded comments on tasks and notes, and the notifications they trigger.","url":"https://docx.payability.com/actionstools-app/api/comments.html","navOrder":6,"source":"= Comments\n:navtitle: Comments\n:description: Threaded comments on tasks and notes, and the notifications they trigger.\n\nNamespace `comments` (`actionstools_app/comments/`), table `em_actions_comments`\n(`_staging` outside production), key `(target_uuid, uuid)`. A comment's `target_uuid` is the task\nor note it belongs to — or another comment, which is how threads nest.\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/comments/`\n| Comments of `X-Actions-TargetUUID`, newest first, each with `comments` (replies) nested two levels.\n\n| POST | `/comments/`\n| Create. Required: `body`, `created_by_key`, `created_by_email`, `target_type` (`task`, `note` or\n  `comment`), `X-Actions-TargetUUID`, `X-InstanceId`. Optional `task_url` for notifications.\n\n| PATCH | `/comments/<uuid>/<target_uuid>/`\n| Update top-level attributes of a comment.\n\n| DELETE | `/comments/<uuid>/<target_uuid>/`\n| Soft delete.\n|===\n\nCreating a comment also *touches* its parent (an empty update refreshes `updated_at`), so the\ntask or note moves to the top of its list. For a task, `do_notify` is queued and notifies the\nassignee, the owner and followers (first 40 characters of the comment). Notes look the parent up\nthrough `instance_id-uuid-index`; a missing note is a 404.\n"},{"component":"actionstools-app","module":"ROOT","path":"api/files.adoc","title":"Files","description":"File attachments: metadata stored here, file bytes stored by the B2B documents service.","url":"https://docx.payability.com/actionstools-app/api/files.html","navOrder":7,"source":"= Files\n:navtitle: Files\n:description: File attachments: metadata stored here, file bytes stored by the B2B documents service.\n\nBlueprint `files` (`actionstools_app/files/views.py`), table `em_actions_files`\n(`_staging` outside production), key `(target_uuid, uuid)`. The file itself lives in B2B\ndocuments (xref:components/b2bdocument.adoc[]); this service keeps the metadata row that the UI\nlists and the activity stream shows.\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| POST | `/files/`\n| Store the *upload response the client received from B2B documents* as a file row. Headers\n  `X-Actions-TargetUUID`, `X-Actions-TargetType` (default `upload-file`) and `X-username` (author email —\n  a header because API Gateway base64-encodes multipart bodies).\n\n| GET | `/files/`\n| Files of the target, newest first (`target_uuid-created_at-index`).\n\n| GET | `/files/<uuid>/`\n| Download: proxies `GET \\{B2BDOCUMENT_API_URL}/download/1/<uuid>` with the caller's headers, cookies and\n  `b2bdocument_token=<X-Session-Token>`, and streams the bytes back.\n\n| DELETE | `/files/<uuid>/<b2bdoc_uuid>/`\n| Deletes the document in B2B documents first (`/delete/1/<b2bdoc_uuid>`), then soft-deletes the row.\n  A 401/403 from B2B documents is returned as-is in `meta`.\n|===\n\nThe upload flow is therefore two calls from the client: upload the file to B2B documents, then\n`POST /files/` with that response.\n"},{"component":"actionstools-app","module":"ROOT","path":"api/settings.adoc","title":"Settings & folders","description":"Global CRM settings (task statuses, priorities, email templates) and email template folders.","url":"https://docx.payability.com/actionstools-app/api/settings.html","navOrder":8,"source":"= Settings & folders\n:navtitle: Settings & folders\n:description: Global CRM settings (task statuses, priorities, email templates) and email template folders.\n\nNamespaces `settings` and `folders` plus the `settings` blueprint\n(`actionstools_app/settings/`), table `em_actions_settings` (`_staging` outside production), key\n`(instance_id, config_key)`. Settings are *global*: every route uses `INSTANCE_ID = 1`\n(`settings/services.py`), regardless of the request's instance header.\n\n== Settings\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/settings/`\n| All settings, every page fetched server-side. The caller must have the required permission in the\n  Payability internal API (`PermissionCheckingValidator`). `?q=` filters are supported.\n\n| POST | `/settings/`\n| Create `{config_key, config_type, config_value}`. `config_type` must be one of the types below and\n  `config_value` is validated by that type's helper.\n\n| GET | `/settings/<config_key>/`\n| Single setting. *Also accepts instance-token auth* (`Authorization: Token …` + instance header) —\n  this is the route other services call.\n\n| PUT | `/settings/<config_key>/`\n| Replace `config_value`, validated against the stored type.\n\n| DELETE | `/settings/<config_key>/`\n| Soft delete.\n\n| GET | `/settings/<config_type>/items`\n| All settings of one type (`instance_id-config_type-index`), with `meta.count`.\n|===\n\n`config_type` values (`settings/models.py`): `string`, `number`, `html`, `list`, `nested_list`,\n`task_template`, `dict`, `json`, `unified_email_config`, `email_config`,\n`email_frontend_automatic_config`, `email_frontend_batch_config`, `email_frontend_config`,\n`email_frontend_template`, `email_base_template`. Types mapped to plain `ConfigType` accept any value.\n\nComplex values are stored in DynamoDB's marshalled form and unmarshalled on read (the `M`/`L`\nchecks in the views).\n\n== Folders\n\nEmail template folders are a single `list` setting whose key is\n`config.TEMPLATES_FOLDER_SETTINGS_KEY` (`template_folders`).\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/folders/` | The folder setting; `config_value` is `[]` if it does not exist yet.\n| POST | `/folders/` | `{\"folder_name\": \"...\"}` appends a folder. 400 if the setting is not a list.\n| DELETE | `/folders/<folder_name>/` | Removes a folder.\n|===\n"},{"component":"actionstools-app","module":"ROOT","path":"api/notifications.adoc","title":"Notifications","description":"In-app notifications per target (usually a supplier): create, list with one filter, mark read or hidden.","url":"https://docx.payability.com/actionstools-app/api/notifications.html","navOrder":9,"source":"= Notifications\n:navtitle: Notifications\n:description: In-app notifications per target (usually a supplier): create, list with one filter, mark read or hidden.\n\nNamespace `notifications` (`actionstools_app/notifications/`), table\n`em_actions_notifications` (`_staging` outside production), key `(target_uuid, uuid)`. Unlike the\nrest of the API, request and response bodies use *camelCase*; `notification_model` maps them to the\nsnake_case attributes in DynamoDB.\n\nAll routes need `X-Actions-TargetUUID` and `X-Actions-TargetType`. Listing and updating also check,\nthrough the Payability internal API, that the user may read/update that supplier\n(`SupplierReadUpdateValidator`).\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Method | Path | Behaviour\n\n| GET | `/notifications/`\n| Newest first. At most *one* of `?createdAfter=<iso datetime>`, `?isRead=true\\|false`,\n  `?isHidden=true\\|false`; more than one is a 400. `meta.count` counts the same query.\n\n| POST | `/notifications/`\n| `{\"notificationType\", \"notificationContent\", \"issuer\"}` → 201. Created unread and visible.\n\n| PATCH | `/notifications/`\n| Batch: `[{\"uuid\", \"isRead\"?, \"isHidden\"?}, ...]`.\n\n| PATCH | `/notifications/<uuid>/`\n| `{\"isRead\"?, \"isHidden\"?}`; at least one is required.\n|===\n\n[NOTE]\n====\n`notificationContent` in the response model maps to the `notification_type` attribute\n(`notifications/models.py`), so responses currently echo the type in both fields. Fixing it\nchanges the API contract for existing clients.\n====\n"},{"component":"actionstools-app","module":"ROOT","path":"api/activity.adoc","title":"Activity stream","description":"One merged, time-ordered stream of notes, files, tasks, events and emails for a supplier.","url":"https://docx.payability.com/actionstools-app/api/activity.html","navOrder":10,"source":"= Activity stream\n:navtitle: Activity stream\n:description: One merged, time-ordered stream of notes, files, tasks, events and emails for a supplier.\n\n`GET /activity/` (`actionstools_app/activity/views.py`) needs `X-Actions-TargetUUID` and returns,\nfor that target, up to `limit` items *of each kind*:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Kind (`type`) | Source\n\n| `note` | `em_actions_notes*` (xref:api/notes.adoc[])\n| `file` | `em_actions_files*` (xref:api/files.adoc[])\n| task | `em_actions_tasks*` (xref:api/tasks.adoc[])\n| event | event logger `/es-changelog` (xref:components/event-logger.adoc[])\n| email | messaging `/email/` (xref:components/messaging.adoc[])\n| batch email | messaging `/email/batch/`\n|===\n\nItems are merged and sorted by `updated_at` (else `created_at`), newest first. With `limit=5` a\npage can therefore hold up to 30 items.\n\nPagination is per source: `meta.last` is an object with one cursor per kind —\n`{\"note\": …, \"file\": …, \"task\": …, \"event\": …, \"email\": …, \"batch\": …}` — and must be sent back\nwhole as `?last=`. It is omitted when every source is exhausted.\n\nThe caller's `X-Session-Token`, `Authorization` and instance header are forwarded to the event\nlogger and messaging services, so the user must be authorised there too. A failure in either\nservice fails the whole request.\n"},{"component":"actionstools-app","module":"ROOT","path":"api/reports.adoc","title":"Reports","description":"Read-only reporting endpoints.","url":"https://docx.payability.com/actionstools-app/api/reports.html","navOrder":11,"source":"= Reports\n:navtitle: Reports\n:description: Read-only reporting endpoints.\n\n`GET /reports/es_cache` (`actionstools_app/reports/views.py`) lists the suppliers waiting for\nthe Elasticsearch transaction cache: every item of `prod-elastic-transaction-cache` (production)\nor `dev-elastic-transaction-cache` (elsewhere) with `to_cache = \"ToCache\"`, oldest first, via\n`to_cache-timestamp-index`. All pages are read server-side; `meta.total` is the count.\n\nThe table belongs to the transaction-cache worker, not to this service. The stack grants\n`ApiFunction` read-only access to it through `ReportsCacheTable` in the `Env` mapping.\n"},{"component":"actionstools-app","module":"ROOT","path":"async/worker.adoc","title":"Worker Lambda & async tasks","description":"How @async_task hands work to the Worker Lambda, the three event shapes the Worker accepts, and failure semantics.","url":"https://docx.payability.com/actionstools-app/async/worker.html","navOrder":12,"source":"= Worker Lambda & async tasks\n:navtitle: Worker & async tasks\n:description: How @async_task hands work to the Worker Lambda, the three event shapes the Worker accepts, and failure semantics.\n\n`WorkerFunction` (`actionstools-<env>-worker`, handler `lambda_handlers.worker_handler`) is the\nonly place background work runs. It replaces Zappa's `@task` decorator and the separate `prod2`\ndeployment.\n\n== `@async_task`\n\n[source,python]\n----\nfrom actionstools_app.tools.async_task import async_task\n\n@async_task\n@handle_async_errors\ndef notify_on_slack(slack_context, email, action_name, name):\n    ...\n----\n\nCalling a decorated function:\n\n* *With `WORKER_FUNCTION_NAME` set* (both deployed functions): serialises\n  `{\"task_path\", \"args\", \"kwargs\"}` with `simplejson` and calls `lambda:Invoke` with\n  `InvocationType=\"Event\"`. The caller gets `None` back immediately.\n* *Without it* (tests, `make run`, CI): runs the function inline, synchronously.\n\nThe undecorated function stays reachable as `fn.sync`; the Worker calls that, so a task never\nre-queues itself.\n\n[IMPORTANT]\n====\n* Arguments must be JSON-serialisable — convert `Decimal`, `datetime`, enums and sets first.\n* The payload limit for asynchronous invokes is 256 KB.\n* There is no Flask request on the Worker. Read anything you need from `request` (for example\n  `get_auth_data()`) *before* calling the task and pass it as an argument.\n====\n\nKeep `@handle_async_errors` inside `@async_task`: it initialises Sentry and logs the exception\nbefore re-raising.\n\n[#events]\n== Events the Worker accepts\n\n`worker_handler` pushes a Flask app context, then dispatches on the event shape:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Event | Source | Runs\n\n| `{\"task_path\": \"actionstools_app…\", \"args\": [...], \"kwargs\": {...}}`\n| `@async_task` (from either function)\n| The task's `.sync`. Paths outside `actionstools_app.` and functions without `.sync` are rejected.\n\n| `{\"Records\": [{\"eventSource\": \"aws:dynamodb\", ...}]}`\n| `TasksStream` event source mapping\n| `counters.task_db_trigger` — xref:async/counters.adoc[]\n\n| `{\"action\": \"remove_counters\"}`\n| `RemoveCountersSchedule`, `rate(4 days)`, production only\n| `counters.remove_counters`\n|===\n\nAnything else raises `ValueError(\"Unrecognised worker event\")`.\n\n.An async task event\n[source,json]\n----\ninclude::example$worker-async-task-event.json[]\n----\n\n== Failure semantics\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Source | On error\n\n| Async task | `EventInvokeConfig.MaximumRetryAttempts: 0` — no retry (Zappa did not retry either). The error is in Sentry and the Worker's CloudWatch logs.\n| Tasks stream | Retried up to 3 times, splitting the batch in half on each failure (`BisectBatchOnFunctionError`), then skipped.\n| Schedule | Not retried; the next run is 4 days later. Re-run by hand (below).\n|===\n\n== Running things by hand\n\n[source,bash]\n----\n# Re-run the counters cleanup\naws lambda invoke --function-name actionstools-prod-worker \\\n  --cli-binary-format raw-in-base64-out \\\n  --payload '{\"action\": \"remove_counters\"}' /dev/stdout\n\n# Run any async task with explicit arguments\naws lambda invoke --function-name actionstools-stage-worker \\\n  --cli-binary-format raw-in-base64-out \\\n  --payload file://task.json /dev/stdout\n----\n\n== Adding a task\n\n. Write a module-level function in `actionstools_app/…` with JSON-friendly parameters.\n. Decorate it `@async_task` (outer) and `@handle_async_errors` (inner).\n. If it touches a new AWS resource, add the permission to *both* functions in `template.yaml`\n  (the API may run it inline in tests, the Worker runs it in production).\n. Add a test in `tests/test_lambda_handlers.py` style: call it with `WORKER_FUNCTION_NAME` unset\n  to exercise the logic, and with a mocked Lambda client to check the payload.\n"},{"component":"actionstools-app","module":"ROOT","path":"async/counters.adoc","title":"Task counters","description":"How the task grid's filter counts are kept without scanning the tasks table on every request.","url":"https://docx.payability.com/actionstools-app/async/counters.html","navOrder":13,"source":"= Task counters\n:navtitle: Task counters\n:description: How the task grid's filter counts are kept without scanning the tasks table on every request.\n\nThe task grid shows a count next to every filter (status, assignee, group, …). Counting with a\nDynamoDB query on each page load would be slow and expensive, so counts are cached per filter\nand kept current from the tasks table stream (`actionstools_app/ddb_counts/`).\n\n== Counter keys\n\nA counter's key is the JSON of its filter list, e.g.\n`[{\"name\": \"status\", \"op\": \"in\", \"val\": [\"new\", \"in-progr\"]}]`, stored in\n`em_actions_tasks_counters` as `{counter_seq_key, current_value, ExpirationTime}`. `created_at`\nvalues are rounded down to the day so that similar queries share a counter.\n\n== Lifecycle\n\n. *First read.* `get_count(query)` (used by `GET /tasks/` and `/tasks/filters/counts/`) finds no\n  counter, creates one with value `0` and a 15-minute TTL, queues `async_calculate_count`, and returns\n  `0`.\n. *Calculation.* On the Worker, `async_calculate_count` counts matching tasks of instance `8001`\n  (`instance_id-uuid-index` with a filter expression) and stores the real value with a 4-day TTL.\n. *Stream updates.* For every batch from the `em_actions_tasks` stream, `task_db_trigger` loads all\n  counters, evaluates each rule against the old and new image, and atomically increments or\n  decrements every counter whose rules now (or no longer) all hold. Soft-deleted tasks count as\n  removed.\n. *Cleanup.* Every 4 days `remove_counters` scans the table and deletes all filter counters\n  (keys starting with `[`); the grid rebuilds them on its next query, so drift from missed stream\n  records cannot last longer than that. Each counter also carries an\n  `ExpirationTime`, which DynamoDB uses to expire it when TTL is enabled on that attribute.\n\n[NOTE]\n====\n`item_number` (the human task number) is a different counter: a single atomic sequence in\n`em_actions_counters`, incremented on task creation.\n====\n\n== Operational notes\n\n* A brand-new filter shows `0` for a moment; the next request shows the calculated value.\n* `task_db_trigger` reads the whole counters table on every batch, so its duration grows with the\n  number of distinct filters in use. Watch the Worker's duration metric if the grid gains filters.\n* In staging the schedule is off (as it was under Zappa); counters there only expire.\n* During the Zappa cutover the stream mapping and the schedule must be active in exactly one\n  deployment — see xref:operations/zappa-cutover.adoc[].\n"},{"component":"actionstools-app","module":"ROOT","path":"data/dynamodb.adoc","title":"DynamoDB tables","description":"Every table the service uses, its keys, indexes and naming per environment.","url":"https://docx.payability.com/actionstools-app/data/dynamodb.html","navOrder":14,"source":"= DynamoDB tables\n:navtitle: DynamoDB tables\n:description: Every table the service uses, its keys, indexes and naming per environment.\n\nTables are defined in `actionstools_app/__init__.py` (`dynamo_tables`) and accessed through\nflask-dynamo. They are *not* managed by the SAM stack; `GET /setup-install` can create missing ones\nin a fresh environment.\n\n== Naming\n\n[cols=\"2,1,1\",options=\"header\"]\n|===\n| Table | Production (`ENV_NAME=production`) | Every other env\n\n| notes | `em_actions_notes2` | `em_actions_notes_staging`\n| comments, files, settings, counters, tasks, tasks_counters, notifications | `em_actions_<name>` | `em_actions_<name>_staging`\n|===\n\nThe suffix is chosen from `ENV_NAME` in code; the table names in the `Env` mapping of\n`template.yaml` must match it for IAM to allow access. Local runs use the `_staging` tables of\nwhatever account the credentials point at.\n\n== Tables\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Table | Key | Indexes\n\n| notes | `target_uuid` / `uuid`\n| GSI `owner_uuid-created_at`, `instance_id-uuid`, `target_uuid-updated_at`; LSI `target_uuid-is_pinned-index`\n\n| comments | `target_uuid` / `uuid`\n| GSI `owner_uuid-created_at`; LSI `target_uuid-created_at-index`\n\n| files | `target_uuid` / `uuid`\n| GSI `target_uuid-created_at`, `owner_uuid-created_at`\n\n| settings | `instance_id` / `config_key`\n| GSI `instance_id-config_type`, `category-created_at`\n\n| tasks | `instance_id` / `uuid`\n| GSI `target_uuid-created_at`, `target_uuid-updated_at`, `owner_uuid-created_at`, `assignee_uuid-created_at`, `status-created_at`;\n  LSI `instance_id-created_at`, `instance_id-created_by`, `instance_id-updated_at`, `instance_id-assignee_name`,\n  `instance_id-status`, `instance_id-created_at-index-copy` (projection subset), `instance_id-uuid`. Stream: `NEW_AND_OLD_IMAGES`.\n\n| counters | `counter_seq_key` | — (task `item_number` sequence)\n\n| tasks_counters | `counter_seq_key` | — (filter counters, `ExpirationTime` TTL attribute)\n\n| notifications | `target_uuid` / `uuid`\n| GSI `target_uuid-created_at`, `target_uuid-is_read`, `target_uuid-is_hidden`\n|===\n\nAll index names end in `-index` (for example `target_uuid-created_at-index`); the constants are\n`INDEX_*` in `actionstools_app/__init__.py`.\n\n== Item conventions\n\n* `uuid` is generated on create; `created_at` / `updated_at` are set by the CRUD service. Tasks use\n  integer epoch seconds (`use_timestamps=True`); the other tables use `str(datetime.utcnow())`.\n* Deletes are *soft*: `deleted_at` is set and the item is filtered out of reads.\n* Free-form payload (`data`, or `config_value` for settings) is stored in marshalled DynamoDB form\n  and unmarshalled on read.\n* Owner-only changes are checked with `OwnerValidator` against the Payability internal API;\n  `CRM_ADMIN` users may change anything.\n"},{"component":"actionstools-app","module":"ROOT","path":"components/event-logger.adoc","title":"Event logger","description":"The EventLoggerApp service: where task change events are written and the activity history is read from.","url":"https://docx.payability.com/actionstools-app/components/event-logger.html","navOrder":15,"source":"= Event logger\n:navtitle: Event logger\n:description: The EventLoggerApp service: where task change events are written and the activity history is read from.\n\n[cols=\"1,3\"]\n|===\n| Setting | `EVENT_LOGGER_APP_URL` (non-secret), `EVENT_LOGGER_API_KEY` (secret)\n| Client | `eventlogger.client.EventLoggerClient` (EventLoggerPySDK, tag `1.0.0`) for writes; plain HTTP for reads\n| Code | `tasks/services.py` (`send_events_and_notifications`), `activity/services.py` (`ChangelogService`)\n|===\n\n== Writes\n\nWhen a logged-in user updates a task, the Worker writes two events with\n`instance_id=8001` and `EVENT_LOGGER_API_KEY`:\n\n* target `task.uuid`, type `CRM_TASK` — the task's own history;\n* target `task.target_uuid`, type `SUPPLIER` — so the change appears in the supplier's activity.\n\nBoth are `EventTypes.TASK` with message `\"<login_id> edited task #<item_number>\"` and the diff in\n`data.changes`.\n\n== Reads\n\n`GET \\{EVENT_LOGGER_APP_URL}/es-changelog` with the caller's `X-Session-Token`, `Authorization`,\n`X-Eventlogger-Instanceid` and `X-EventLogger-TargetUUID`. Used by `GET /tasks/<uuid>/events/`\nand `GET /activity/`. A non-200 raises and fails the request.\n"},{"component":"actionstools-app","module":"ROOT","path":"components/messaging.adoc","title":"Messaging (email)","description":"The MessagingApp service: task notification emails out, supplier email history in.","url":"https://docx.payability.com/actionstools-app/components/messaging.html","navOrder":16,"source":"= Messaging (email)\n:navtitle: Messaging\n:description: The MessagingApp service: task notification emails out, supplier email history in.\n\n[cols=\"1,3\"]\n|===\n| Setting | `MESSAGING_APP_URL` (non-secret), `MESSAGING_API_KEY` (secret)\n| Client | `messagingapp.client.MessagingAppClient` (MessagingPySDK, tag `2.0.0`), configured by `config.MsgClientParams`\n| Code | `tasks/notifications.py` (`SlackNotifier._notify`), `activity/services.py` (`MessangingService`)\n|===\n\n== Notification emails\n\n`SlackNotifier` sends one email per recipient and action with the `default_config` template,\nsubject `[TASKS NOTIFICATION] #<number> <title>`, HTML and text bodies from\n`TEMPLATES[\"email\"]`, and `message_type=\"task notification\"`. Actions: `edit`, `assigned`,\n`commented`, `resolved`.\n\nRecipients are deduplicated per notifier. Addresses in `NOTIFICATIONS_EMAIL_BLACKLIST`\n(`config.py`) and empty addresses are skipped. A `MessageError` is logged and does not stop the\nother recipients.\n\n== Email history\n\n`GET /activity/` reads `\\{MESSAGING_APP_URL}/email/` and `/email/batch/` for the target, forwarding\nthe caller's credentials. A non-200 fails the request.\n"},{"component":"actionstools-app","module":"ROOT","path":"components/b2bdocument.adoc","title":"B2B documents","description":"The document storage service that holds the bytes of every file attachment.","url":"https://docx.payability.com/actionstools-app/components/b2bdocument.html","navOrder":17,"source":"= B2B documents\n:navtitle: B2B documents\n:description: The document storage service that holds the bytes of every file attachment.\n\n[cols=\"1,3\"]\n|===\n| Setting | `B2BDOCUMENT_API_URL` (default `https://doc-api-test2.payability.com/b2bdocument/api`)\n| GCI | `config.GCI_ID = 1`\n| Code | `files/views.py`\n|===\n\n* *Upload* goes straight from the browser to B2B documents; the client then posts the response\n  to `POST /files/`.\n* *Download* `GET \\{B2BDOCUMENT_API_URL}/download/1/<uuid>?b2bdocument_token=<X-Session-Token>`.\n* *Delete* `GET \\{B2BDOCUMENT_API_URL}/delete/1/<b2bdoc_uuid>?b2bdocument_token=<X-Session-Token>`\n  (it is a GET on their side).\n\nRequests forward the caller's headers minus `Host` and the `X-Actions-*` context headers.\n\n[WARNING]\n====\nBoth environments currently point at `http://52.207.166.124:8080/b2bdocument/api` — a plain-HTTP\nIP address. From the production VPC this is reachable as before; keep it in mind if the service\nmoves or the Lambdas leave the VPC.\n====\n"},{"component":"actionstools-app","module":"ROOT","path":"components/payability-internal.adoc","title":"Payability internal API","description":"The internal API (database_v2) used for permission checks and user lookups.","url":"https://docx.payability.com/actionstools-app/components/payability-internal.html","navOrder":18,"source":"= Payability internal API\n:navtitle: Payability internal API\n:description: The internal API (database_v2) used for permission checks and user lookups.\n\n[cols=\"1,3\"]\n|===\n| Setting | `PAYABILITY_INTERNAL_API_URL` — `https://internal.payability.com/bin/internal/` (prod), `https://staging.dev.payability.com/database_v2/internal/` (stage)\n| Code | `tools/utils.py`, validators in `pyb_flask_utils.access`\n|===\n\nUses, all with the caller's `X-Session-Token`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Call | Purpose\n\n| `OwnerValidator(\"CRM_ADMIN\", …)` | May this user change owner-only fields of an item? (tasks, comments, files)\n| `UserReadOnlyValidator` | Read-only users.\n| `PermissionCheckingValidator` | `GET /settings/`.\n| `SupplierReadUpdateValidator` | May this user read/update this supplier? (notifications)\n| `GET /logins/<uuid>` (`get_user`) | Owner email for notifications; creator email when a task is resolved.\n|===\n\n`pyb_flask_utils.auth` also reads `PAYABILITY_INTERNAL_API_URL` at import time — it must be set\nbefore the app is imported (it is: `secrets_loader` and the Lambda environment).\n\nBoth environments run in a VPC so this call is not made from a public Lambda IP.\nProduction uses `subnet-adecc0c8`, `subnet-4f415d16`, and `sg-6c6f230a`.\nStage uses the dev VPC (`subnet-0f2f29c7510986dd0`, `subnet-0b027bfee9b60750e`, `sg-05fdec837243c8245`);\n`staging.dev.payability.com` returns 403 to public Lambda addresses and 200 to that NAT.\n"},{"component":"actionstools-app","module":"ROOT","path":"components/slack.adoc","title":"Slack","description":"Direct-message task notifications through a Slack bot token.","url":"https://docx.payability.com/actionstools-app/components/slack.html","navOrder":19,"source":"= Slack\n:navtitle: Slack\n:description: Direct-message task notifications through a Slack bot token.\n\n[cols=\"1,3\"]\n|===\n| Setting | `SLACK_TOKEN` (secret)\n| Client | `slack_sdk.WebClient`\n| Code | `tasks/notifications.py` (`notify_on_slack`, `SlackCache`)\n|===\n\nEvery notification to a recipient whose task `assignee_type` is `user` also queues\n`notify_on_slack`. On the Worker it:\n\n. renders `TEMPLATES[\"slack\"][action]` (HTML in the change list is flattened to text);\n. finds the Slack user whose profile email equals the recipient's email, using `users.list`\n  cached per execution environment (`SlackCache`, retried up to 5 times on a `KeyError`);\n. posts `chat.postMessage` to `@<username>` as \"Task notifications\" with `:exclamation:`.\n\nUnknown users and `SlackApiError` are logged as errors and otherwise ignored.\n\nScopes needed: `chat:write`, `chat:write.customize` (custom username and icon), `users:read`,\n`users:read.email`.\n\n[IMPORTANT]\n====\nThe Zappa deployment used a Slack *user* token (`xoxp-…`) that is committed to this repository's\nhistory (`zappa_settings.json`). Replace it with a bot token (`xoxb-…`) of a dedicated Slack app\nwith the scopes above, store it as `SLACK_TOKEN` in `actionstools/<env>`, and revoke the old token.\n====\n"},{"component":"actionstools-app","module":"ROOT","path":"components/sentry.adoc","title":"Sentry","description":"Error reporting for both Lambda functions.","url":"https://docx.payability.com/actionstools-app/components/sentry.html","navOrder":20,"source":"= Sentry\n:navtitle: Sentry\n:description: Error reporting for both Lambda functions.\n\n[cols=\"1,3\"]\n|===\n| Setting | `SENTRY_DSN` (secret)\n| SDK | `sentry-sdk` 2.x with the Flask and logging integrations\n| Code | `tools/utils.py` (`setup_sentry`), called by `run.py` and by `with_error_handler`\n|===\n\nOutside `ENV_NAME` `dev`, `local` and `test`, `setup_sentry()` initialises the SDK with\n`environment=ENV_NAME` (`production`, `staging`). Log records at `WARNING` and above become Sentry\nevents — provided they pass the root level `LOG_LEVEL`, which is `ERROR` in production. Every\n`logger.error` / `logger.exception` in a view or task is therefore reported; warnings only on stage.\n\nAsync tasks and the stream handler are wrapped in `with_error_handler`, which logs the\nexception with context and re-raises, so the Worker invocation is also marked as failed in\nCloudWatch.\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/local-development.adoc","title":"Local development","description":"Install, test and run the API locally.","url":"https://docx.payability.com/actionstools-app/operations/local-development.html","navOrder":21,"source":"= Local development\n:navtitle: Local development\n:description: Install, test and run the API locally.\n\n== Prerequisites\n\n* https://docs.astral.sh/uv/[uv] (it installs Python {python-version} itself, pinned by `.python-version`).\n* Git access to the private `github.com/Payability` repos — `uv` fetches `pyb-flask-utils`,\n  `eventlogger` and `messaging` from git tags (`[tool.uv.sources]` in `pyproject.toml`).\n* AWS SAM CLI, only for `make build` / `make deploy`.\n* AWS SSO profiles `dev` (account {dev-aws-account}) and `prod` (account {prod-aws-account}).\n\n== Everyday commands\n\n[source,bash]\n----\nmake install    # uv sync: .venv with runtime + dev dependencies\nmake test       # offline unit tests\nmake lint       # ruff (pyflakes rules)\nmake help       # everything else\n----\n\nDependencies are declared in `pyproject.toml` and locked in `uv.lock`. Add one with\n`uv add <pkg>==<version>` (or `uv add --dev …`), commit both files. `requirements.txt` is generated\nfor `sam build` by `make requirements` and is not committed.\n\n== Tests\n\n`make test` runs `python -m unittest discover -s tests -t .`. The suite needs no AWS access:\n\n* `tests/__init__.py` sets `AWS_SECRETS_MANAGER=False` and dummy values for every required setting\n  *before* the app is imported (`-t .` makes `tests` a package so this runs first);\n* DynamoDB is mocked with moto (`mock_aws`, tables created from `dynamo_tables`);\n* `WORKER_FUNCTION_NAME` is unset, so `@async_task` functions run inline;\n* HTTP calls to other services are mocked with `requests-mock` / `mock`.\n\n== Running the API locally\n\n[source,bash]\n----\ncp .env.example .env    # fill in the values (see the comments in the file)\nmake run                # uv run --env-file .env: Flask under gevent on http://localhost:5555\n----\n\nPyCharm users: the committed run configuration `.idea/runConfigurations/Run.xml` loads `.env`\nthrough the EnvFile plugin and runs `run_locally.py`.\n\nTwo ways to provide secrets locally:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Mode | Set\n\n| Explicit values (default in `.env.example`)\n| `AWS_SECRETS_MANAGER=False` and every secret as a plain variable. `pyb_flask_utils` then also skips `common/crm`, so instance-token auth is off; JWT auth still works, since tokens are validated by the stage internal API (`PAYABILITY_INTERNAL_API_URL`).\n\n| From the stage secret\n| `AWS_SECRETS_MANAGER=True`, `APP_SECRET_ID=actionstools/stage`, `AWS_PROFILE=dev` after `aws sso login --profile dev`. Behaves like the deployed stage.\n|===\n\nThe local app uses the `_staging` tables of whichever account your credentials point at.\n\nThe Lambda entry points in `lambda_handlers.py` are not run locally; `tests/test_lambda_handlers.py`\ncovers the API Gateway translation and every Worker event shape\n(xref:async/worker.adoc#events[]). To exercise the real Worker, invoke the stage one by hand\n(xref:async/worker.adoc[Running things by hand]).\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/configuration.adoc","title":"Configuration & secrets","description":"Where every setting comes from, how secrets reach the code, and how to change them.","url":"https://docx.payability.com/actionstools-app/operations/configuration.html","navOrder":22,"source":"= Configuration & secrets\n:navtitle: Configuration & secrets\n:description: Where every setting comes from, how secrets reach the code, and how to change them.\n\n== Loading model\n\nAll configuration is read from `os.environ` — by `config.py` (the Flask config object) and, at\nimport time, by `pyb_flask_utils`. Values arrive from three places, highest precedence first:\n\n. *Lambda environment variables*, set by `template.yaml` from its `Env` mapping, keyed by the\n  `EnvName` parameter (the only value `samconfig.toml` passes) — non-secret values only (URLs,\n  log level, `ENV_NAME`, the secret's name).\n. *`actionstools/<env>`* in Secrets Manager, copied into `os.environ` by `secrets_loader.py` at\n  cold start with `setdefault`, so a Lambda environment variable with the same name wins.\n. *`common/crm`*, the shared CRM secret, read by `pyb_flask_utils.auth` into its own dictionary\n  (it is not copied to `os.environ`): the instance-id → token pairs for `Authorization: Token …`\n  and a fallback `AUTH_JWT_RSA_PUBLIC_KEY`.\n\n[source,python]\n----\nsecret_id = os.environ.get(\"APP_SECRET_ID\")\nif not secret_id or os.environ.get(\"AWS_SECRETS_MANAGER\") == \"False\":\n    return\n----\n\n[IMPORTANT]\n====\n`secrets_loader` must be imported before anything else reads the environment. It is the first\nimport of `config.py` and of `actionstools_app/__init__.py`; keep it there (`# noqa: F401`). A\nmodule-level `os.environ[...]` in new code is fine as long as that module is imported after\n`config`.\n====\n\n== The service secret\n\nOne JSON secret per account, holding only secrets:\n\n[source,json]\n----\ninclude::example$secret-actionstools.json[]\n----\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Env | Secret | Account\n\n| prod | `actionstools/prod` | {prod-aws-account}\n| stage | `actionstools/stage` | {dev-aws-account}\n|===\n\nThe functions' IAM roles allow `GetSecretValue` on exactly this secret and `common/crm`\n(`AWSSecretsManagerGetSecretValuePolicy`). Every key is also documented in\nxref:reference/settings.adoc[].\n\n== Changing a value\n\n*A secret.* Update the secret, then force new execution environments — secrets are read once\nper cold start:\n\n[source,bash]\n----\naws secretsmanager put-secret-value --secret-id actionstools/stage \\\n  --secret-string file://actionstools-stage.json --profile dev\n# any configuration change recycles the environments, e.g. re-deploy:\nAWS_PROFILE=dev make deploy stage=stage\n----\n\n`put-secret-value` replaces the whole JSON; fetch it first with `get-secret-value`, edit, and\nwrite it back.\n\n*A non-secret value.* Edit the environment's column of the `Env` mapping in `template.yaml`\n(for a new value, add the key to *both* `prod` and `stage` and read it with\n`!FindInMap [Env, !Ref EnvName, <Key>]`), commit, deploy. Do not add it to `parameter_overrides`.\n\n*A new secret key.* Add it to both secrets, read it in `config.py` with an `assert` if it is\nrequired, add a dummy value to `tests/__init__.py` and a placeholder to `.env.example`, and\ndocument it in xref:reference/settings.adoc[].\n\n== Environments\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| | prod | stage\n\n| Stack | `{prod-stack}` | `{stage-stack}`\n| `ENV_NAME` | `production` | `staging`\n| `BASE_PREFIX` | `/actionstools` | (empty)\n| Tables | `em_actions_*`, `em_actions_notes2` | `em_actions_*_staging`\n| VPC | `subnet-adecc0c8`, `subnet-4f415d16`, `sg-6c6f230a` | `subnet-0f2f29c7510986dd0`, `subnet-0b027bfee9b60750e`, `sg-05fdec837243c8245`\n| Counters schedule | on | off\n| Swagger | at the base URL | at the base URL\n|===\n\n== Gotchas\n\n* *Secrets are cached for the life of an execution environment.* After a rotation, old\n  environments keep the old value until they are recycled.\n* *`AWS_SECRETS_MANAGER=False` disables both secrets*, including `common/crm`.\n* *`ENV_NAME` selects table names*, not just labels: anything other than `production` uses the\n  `_staging` tables.\n* *The secret's name, not its ARN.* `APP_SECRET_ID` is the name; Secrets Manager ARNs end in a\n  random suffix, which is why IAM policies use `<name>-*`.\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/deployment.adoc","title":"Deployment","description":"How the SAM stack is built and deployed, by GitHub Actions and by hand, and what CI needs.","url":"https://docx.payability.com/actionstools-app/operations/deployment.html","navOrder":23,"source":"= Deployment\n:navtitle: Deployment\n:description: How the SAM stack is built and deployed, by GitHub Actions and by hand, and what CI needs.\n\n== Environments\n\n`samconfig.toml` has one config environment per stack. `global` parameters (stack name, region,\nand `EnvName` as the only parameter override) apply to `build`, `deploy` and `validate`.\nEverything else that differs between environments is in the `Env` mapping of `template.yaml`.\n\n[cols=\"1,2,2,2\",options=\"header\"]\n|===\n| `stage=` | Stack | Account | Deployed by\n\n| `prod` | `{prod-stack}` | {prod-aws-account} | GitHub Actions on every push to `release`\n| `stage` | `{stage-stack}` | {dev-aws-account} | GitHub Actions on every push to `develop`\n|===\n\n== By hand\n\n[source,bash]\n----\naws sso login --profile dev\nAWS_PROFILE=dev make deploy stage=stage\n----\n\n`make deploy` runs, in order:\n\n. `uv export --frozen --no-dev … -o requirements.txt` — the locked runtime dependencies.\n. `uv run sam build --config-env <stage>` — `uv run` puts `.venv`'s Python {python-version} (with\n  setuptools) first on `PATH`; SAM's pip builder uses it to build the git-sourced SDKs.\n. `sam deploy --config-env <stage> --no-confirm-changeset --no-fail-on-empty-changeset` — uploads to\n  the SAM-managed bucket (`resolve_s3`) and applies a change set with `CAPABILITY_IAM`.\n\nPreview a change without applying it:\n\n[source,bash]\n----\nmake build stage=stage\nAWS_PROFILE=dev sam deploy --config-env stage --confirm-changeset\n----\n\n[#ci]\n== CI/CD (GitHub Actions)\n\nBoth workflows run the same `make` targets as a developer, so there are no CI-only scripts. uv\nand the SAM CLI are pinned by `UV_VERSION` / `SAM_CLI_VERSION` at the top of each workflow.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Workflow | Runs on | Does\n\n| `test.yml`\n| every pull request; called by `deploy.yml`\n| `uv sync --frozen` (fails if `uv.lock` is stale), `make lint`, `make test`, and\n  `sam validate --lint` for both config environments. No AWS access.\n\n| `deploy.yml`\n| push to `develop` or `release`\n| Runs `test.yml`, then `make deploy stage=<env>` and a smoke test: an unauthenticated\n  `GET <ApiUrl>notes/` must answer 4xx. A 5xx means the cold start failed, usually because of a\n  missing secret.\n|===\n\nA push to `develop` deploys `stage` and a push to `release` deploys `prod`; no other branch\ndeploys, and there is no manual trigger (use *Re-run jobs* on a run to deploy the same commit\nagain, or deploy by hand). Deploys to one environment are serialised (`concurrency`), and the\njob runs in the GitHub environment of the same name: add *required reviewers* to the `prod`\nenvironment to gate production deploys.\n\n=== Secrets\n\nAll three are *organisation* secrets shared with this repository; the repository defines none\nof its own.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Secret | Used for\n\n| `PYB_GITHUB_TOKEN`\n| A token with read access to the private `github.com/Payability` SDK repos, put in a\n  `url.insteadOf` rule so `uv sync` can fetch them. The step fails with an explicit error if it\n  is empty.\n\n| `DEV_AWS_ACCESS_KEY_ID`, `DEV_AWS_SECRET_ACCESS_KEY`\n| Deploy user in the dev account ({dev-aws-account}), for `stage`.\n\n| `PROD_AWS_ACCESS_KEY_ID`, `PROD_AWS_SECRET_ACCESS_KEY`\n| Deploy user in the prod account ({prod-aws-account}), for `prod`.\n|===\n\nList what the repository can see with\n`gh api repos/Payability/ActionsToolsApp/actions/organization-secrets --jq '.secrets[].name'`.\nSecrets are not passed to workflows triggered by Dependabot or by pull requests from forks, so\nthose test runs fail at the SDK step.\n\n[#deploy-permissions]\n=== Permissions of the deploy user\n\nSAM deploys through CloudFormation, so the deploy identity needs CloudFormation, IAM (the stack\ncreates the functions' roles), Lambda, API Gateway, EventBridge and the SAM artifacts bucket. A\npolicy scoped to the prod stack (for stage, replace the account and `prod` with\n{dev-aws-account} and `stage`):\n\n[source,json]\n----\ninclude::example$ci-deploy-policy.json[]\n----\n\nThe first `sam deploy` in an account creates the `aws-sam-cli-managed-default` stack and its\nbucket; if the deploy user should not be allowed to do that, run the first deploy by hand.\n\n== Rollback\n\n* *Bad code:* revert the commit on `release`; CI redeploys the previous version. To skip CI, deploy\n  the previous commit by hand: `git checkout <sha> && AWS_PROFILE=prod make deploy stage=prod`.\n* *Stuck update:* `aws cloudformation cancel-update-stack --stack-name actionstools-prod` while it is\n  in progress.\n* *Failed update:* CloudFormation rolls back automatically; the stack events name the failing\n  resource.\n\nData is never touched by a rollback: the tables are outside the stack.\n\n== Smoke test after a deploy\n\n[source,bash]\n----\nURL=$(aws cloudformation describe-stacks --stack-name actionstools-stage \\\n  --query \"Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue\" --output text --profile dev)\ncurl -s -o /dev/null -w '%{http_code}\\n' \"$URL\"                        # 200, Swagger UI\ncurl -s \"$URL/notes/\" -H \"X-Session-Token: $TOKEN\" \\\n  -H \"X-Actions-TargetUUID: <supplier key>\" | jq .status_code          # 200\nsam logs --stack-name actionstools-stage --profile dev --tail          # API and Worker logs\n----\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/zappa-cutover.adoc","title":"Zappa → SAM cutover","description":"The one-time runbook that moves stage and production from the Zappa Lambdas to the SAM stacks without downtime.","url":"https://docx.payability.com/actionstools-app/operations/zappa-cutover.html","navOrder":24,"source":"= Zappa → SAM cutover\n:navtitle: Zappa → SAM cutover\n:description: The one-time runbook that moves stage and production from the Zappa Lambdas to the SAM stacks without downtime.\n\nUntil this runbook is finished, both deployments exist side by side. The SAM stacks start with\n`CountersEvents: 'false'` in the `Env` mapping of `template.yaml`, so the tasks stream and the cleanup schedule keep running on Zappa\nuntil step 5. Every step is reversible until step 6.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| | Zappa (before) | SAM (after)\n\n| API | `actionstoolsapp-prod` (+ its API Gateway) | `actionstools-prod-api` + `ActionsToolsApi`\n| Background | `@task` → `actionstoolsapp-prod`; counters → `actionstoolsapp-prod2` | `actionstools-prod-worker`\n| Secrets | Lambda env vars, `zappa_settings.json`, code | `actionstools/prod` + `common/crm`\n| Stage | `actionstoolsapp-stage` | `{stage-stack}`\n|===\n\n== 0. Prerequisites\n\nBefore merging the migration branch: add the repository secrets listed in\nxref:operations/deployment.adoc#ci[], create the Secrets Manager secrets (step 1) so the first\nautomatic stage deploy passes its smoke test, and delete the CodeBuild project `ActionsToolsApp`\n(or its GitHub webhook) — `buildspec.yml` is gone, so it would fail on every push.\n\n[source,bash]\n----\naws sso login --profile prod && aws sso login --profile dev\n----\n\nExport the live configuration — it is the source of truth for secret values and for any variable\nthat is not in the `Env` mapping of `template.yaml`:\n\n[source,bash]\n----\nfor fn in actionstoolsapp-prod actionstoolsapp-prod2; do\n  aws lambda get-function-configuration --function-name $fn --profile prod \\\n    --query 'Environment.Variables' > /tmp/$fn.env.json\ndone\naws lambda get-function-configuration --function-name actionstoolsapp-stage --profile dev \\\n  --query 'Environment.Variables' > /tmp/actionstoolsapp-stage.env.json\n----\n\nCompare the non-secret values with the `Env` mapping in `template.yaml` and fix any\ndifference before deploying. Delete the `/tmp` files when done.\n\n== 1. Secrets\n\nRotate what was committed to git, then create the secrets (shape:\nxref:operations/configuration.adoc#the-service-secret[]):\n\n* `SLACK_TOKEN` — new bot token (xref:components/slack.adoc[]).\n* `MESSAGING_API_KEY`, `EVENT_LOGGER_API_KEY` — the instance-8001 key was hardcoded in `config.py`\n  and the README. Issue a new one in both services, and update `common/crm` if the same pair is\n  stored there.\n* `AUTH_JWT_RSA_PUBLIC_KEY`, `SENTRY_DSN` — copy from the export.\n\n[source,bash]\n----\naws secretsmanager create-secret --name actionstools/stage --profile dev \\\n  --secret-string file://actionstools-stage.json\naws secretsmanager create-secret --name actionstools/prod --profile prod \\\n  --secret-string file://actionstools-prod.json\n----\n\nThe long-lived `X-Session-Token` that `scripts/` used to contain (login `pyb_evt_api`, expiry 2045)\nis also in git history; revoke it in the internal API.\n\n== 2. Stage\n\n[source,bash]\n----\nAWS_PROFILE=dev make deploy stage=stage\n----\n\nSmoke test (xref:operations/deployment.adoc#smoke-test-after-a-deploy[]): Swagger, list notes, create\nand update a task, add a comment, and check the Worker log shows the notification tasks.\n\nMove the stream to SAM:\n\n[source,bash]\n----\nSTREAM=arn:aws:dynamodb:us-east-1:605668041275:table/em_actions_tasks_staging/stream/2021-01-28T09:15:34.056\naws lambda list-event-source-mappings --event-source-arn $STREAM --profile dev \\\n  --query 'EventSourceMappings[].[UUID,FunctionArn,State]' --output table\naws lambda update-event-source-mapping --uuid <zappa mapping uuid> --no-enabled --profile dev\n----\n\nSet `CountersEvents: 'true'` under `stage` in the `Env` mapping of `template.yaml`, deploy again,\nand confirm a task edit changes a grid count. Point the stage UI at the new `ApiUrl`.\n\n== 3. Production, side by side\n\n[source,bash]\n----\nAWS_PROFILE=prod make deploy stage=prod    # or merge to release and let CI deploy\n----\n\nThe new API answers on its execute-api URL only:\n`https://<ApiId>.execute-api.us-east-1.amazonaws.com/prod/actionstools/`. Run the smoke test\nagainst it. Async tasks queued from it run on the new Worker; counters still come from Zappa.\n\n== 4. Switch traffic\n\nThe custom domain `api.payability.com` maps the base path `actionstools` to Zappa's REST API.\nRepoint it — a single, instant change:\n\n[source,bash]\n----\naws apigateway get-base-path-mappings --domain-name api.payability.com --profile prod\naws apigateway update-base-path-mapping --domain-name api.payability.com \\\n  --base-path actionstools --profile prod \\\n  --patch-operations op=replace,path=/restapiId,value=<ApiId output of actionstools-prod>\n----\n\nIf `get-base-path-mappings` shows a different layout (for example a `(none)` base path, or the\ndomain fronted by something other than API Gateway), keep the layout and only swap the REST API id\nand stage: the app expects `event[\"path\"]` to start with `/actionstools`\n(xref:request-lifecycle.adoc#paths[]).\n\n*Rollback:* the same command with Zappa's REST API id (recorded from `get-base-path-mappings`).\n\nWatch Sentry and `sam logs --stack-name actionstools-prod --profile prod --tail` for an hour.\n\n== 5. Move the stream and the schedule\n\nExactly one consumer must update counters. Disable Zappa's, then enable SAM's:\n\n[source,bash]\n----\nSTREAM=arn:aws:dynamodb:us-east-1:625663093496:table/em_actions_tasks/stream/2017-09-25T09:09:29.615\naws lambda list-event-source-mappings --event-source-arn $STREAM --profile prod \\\n  --query 'EventSourceMappings[].[UUID,FunctionArn,State]' --output table\naws lambda update-event-source-mapping --uuid <each zappa uuid> --no-enabled --profile prod\n\nfor fn in actionstoolsapp-prod actionstoolsapp-prod2; do\n  ARN=$(aws lambda get-function --function-name $fn --query Configuration.FunctionArn --output text --profile prod)\n  aws events list-rule-names-by-target --target-arn $ARN --profile prod\ndone\naws events disable-rule --name <each zappa rule> --profile prod\n----\n\nSet `CountersEvents: 'true'` under `prod` in the `Env` mapping, merge to `release`. After the deploy,\nreset counters so drift from the gap is discarded:\n\n[source,bash]\n----\naws lambda invoke --function-name actionstools-prod-worker --profile prod \\\n  --cli-binary-format raw-in-base64-out --payload '{\"action\": \"remove_counters\"}' /dev/stdout\n----\n\n*Rollback:* set the flag back to `false`, deploy, re-enable the Zappa mapping and rules.\n\n== 6. Remove Zappa\n\nAfter a few quiet days:\n\n. Delete the functions `actionstoolsapp-prod`, `actionstoolsapp-prod2` (prod) and\n  `actionstoolsapp-stage` (dev) — this also removes their event source mappings.\n. Delete the CloudWatch Events rules found in step 5 (and stage's).\n. Delete Zappa's API Gateway stacks: `aws cloudformation delete-stack --stack-name actionstoolsapp-prod`\n  (and `-stage` in dev). Check `get-base-path-mappings` first — nothing may still point at them.\n. Empty and delete the Zappa buckets `app-actionstools--us-east-1` and `app-actionstools-staging-dev`\n  if nothing else uses them.\n\n[WARNING]\n====\nDo *not* delete the IAM role `ZappaLambdaExecution`: other services (DocumentsApp among them) still\nuse it. The new stack creates its own roles.\n====\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/observability.adoc","title":"Observability","description":"Where logs, errors and metrics for both functions live, and what to watch.","url":"https://docx.payability.com/actionstools-app/operations/observability.html","navOrder":25,"source":"= Observability\n:navtitle: Observability\n:description: Where logs, errors and metrics for both functions live, and what to watch.\n\n== Logs\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Log group | Contents\n\n| `/aws/lambda/actionstools-<env>-api` | HTTP requests, view errors, `Queued async task …` (at `DEBUG`).\n| `/aws/lambda/actionstools-<env>-worker` | Async tasks, stream batches, the cleanup run (`… counters removed`).\n| API Gateway execution logs | Not enabled; turn on per stage when debugging routing.\n|===\n\nBoth Lambda log groups are resources of the stack (`ApiLogGroup`, `WorkerLogGroup`), wired to the\nfunctions through `LoggingConfig`. Retention is `LogRetentionDays` in the `Env` mapping: *90 days*\nin prod, *14* on stage. Deleting the stack deletes them.\n\n[NOTE]\n====\nIf `/aws/lambda/actionstools-<env>-api` or `-worker` already exists (a function was invoked\nbefore the log groups were part of the template), the deploy fails with `already exists`. Delete\nthe group, or import it into the stack, and deploy again.\n====\n\nThe root log level is `LOG_LEVEL` (`ERROR` in prod, `WARNING` on stage), set in\n`lambda_handlers.py`. Tail both functions at once:\n\n[source,bash]\n----\nsam logs --stack-name actionstools-prod --profile prod --tail\nsam logs --stack-name actionstools-prod --profile prod -n WorkerFunction --filter ERROR -s '1h ago'\n----\n\n== Errors\n\nSentry (xref:components/sentry.adoc[]) receives log records at `WARNING`+ that also pass\n`LOG_LEVEL` — so `ERROR`+ in production — from both functions, tagged with `environment` =\n`production` / `staging`. Async-task failures carry the message prefix\n`Error when sending notifications in background`; stream failures `Error when calculating counters`.\n\n== Metrics worth an alarm\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Metric | Function | Meaning\n\n| `Errors`, `Throttles` | both | Unhandled exceptions / concurrency limits.\n| `Duration` (p99) close to 120 s | API | Slow downstream (internal API, event logger, messaging).\n| `IteratorAge` | Worker | The tasks stream is falling behind; counters are stale.\n| `AsyncEventsDropped` | Worker | Async tasks discarded after failure or age — notifications lost.\n| `5XXError`, `Latency` | `ActionsToolsApi` | What users see.\n|===\n"},{"component":"actionstools-app","module":"ROOT","path":"operations/troubleshooting.adoc","title":"Troubleshooting","description":"Symptoms, causes and fixes for the failures seen so far.","url":"https://docx.payability.com/actionstools-app/operations/troubleshooting.html","navOrder":26,"source":"= Troubleshooting\n:navtitle: Troubleshooting\n:description: Symptoms, causes and fixes for the failures seen so far.\n\n== Cold start fails (every request 502)\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Log line | Cause → fix\n\n| `AccessDeniedException … GetSecretValue`\n| The secret is not named `actionstools/<EnvName>`, or is in another account/region. Fix the\n  secret; the name and the IAM policy are derived from `EnvName` in `template.yaml`.\n\n| `AssertionError: set env variable MESSAGING_API_KEY` (or another)\n| The key is missing from `actionstools/<env>`. Add it, then recycle (redeploy).\n\n| `missing AUTH_JWT_RSA_PUBLIC_KEY secret variable`\n| Neither `actionstools/<env>` nor `common/crm` holds the public key.\n\n| `KeyError: 'PAYABILITY_INTERNAL_API_URL'` in `pyb_flask_utils/auth.py`\n| Something imported `pyb_flask_utils` before `secrets_loader`/`config`. Restore the import order\n  (xref:operations/configuration.adoc[]).\n|===\n\n== Requests fail\n\n* *All requests 400 `Unauthorized`* — the internal API's `token/validate` rejected the token (a token\n  from another environment, or expired), `PAYABILITY_INTERNAL_API_URL` is unreachable from the\n  function, or `X-Session-Token` is missing.\n* *404 for every path in production* — `event[\"path\"]` does not start with `/actionstools`: the\n  custom domain base path mapping changed (xref:request-lifecycle.adoc#paths[]).\n* *Timeouts on tasks, settings or notifications* — the call to the Payability internal API hangs.\n  Check the function still has its VPC config and the subnets have a route to that API.\n* *400 `Unable to parse JWT token` with `403` from `token/validate`* — the function is calling\n  `staging.dev.payability.com` from a public Lambda IP. Stage must use the dev VPC in `template.yaml`.\n* *CORS errors in the browser* — preflight is answered by API Gateway; check the `Cors` block in\n  `template.yaml` lists the header the UI added.\n\n== Background work does not happen\n\n. Is `WORKER_FUNCTION_NAME` set on the API function? Without it, tasks run inline (slow, but they\n  run).\n. API log `AccessDeniedException … lambda:InvokeFunction` — the `LambdaInvokePolicy` is missing.\n. API log `RequestEntityTooLargeException` — the task arguments exceed 256 KB; pass ids, not objects.\n. Worker log `Unrecognised worker event` or `Refusing to run task outside actionstools_app.` — the\n  payload was built by hand or the function moved; check `task_path`.\n. Worker log `RuntimeError: Working outside of request context` — the task reads Flask `request`.\n  Known case: `async_create_task` (task creation with `_mode: async`).\n. Slack only: `User … cannot be found on slack` — the recipient's Slack profile email differs from\n  the CRM email.\n\n== Grid counts are wrong\n\n* Make sure exactly one consumer is enabled on the tasks stream\n  (`aws lambda list-event-source-mappings --event-source-arn <stream arn>`).\n* Reset: invoke the Worker with `{\"action\": \"remove_counters\"}`; counts are recalculated on the\n  next request (xref:async/counters.adoc[]).\n\n== Build and local issues\n\n* *`UnauthorizedSSOTokenError` on import* — local runs try to read Secrets Manager. Run\n  `aws sso login --profile dev` or set `AWS_SECRETS_MANAGER=False`.\n* *`sam build`: `Could not satisfy the requirement: setuptools`* — pip cannot reach its index.\n  A global pip config pointing at CodeArtifact with an expired token is the usual cause:\n  `PIP_INDEX_URL=https://pypi.org/simple make build`, or refresh the CodeArtifact login.\n* *`sam build`: `Unable to retrieve name/version for package: pyb-flask-utils`* — SAM ran outside\n  `uv run` and found no usable `python3.12`. Use `make build`.\n* *`uv sync`: authentication failed for github.com/Payability* — configure git credentials for the\n  private repos (CI uses the `PYB_GITHUB_TOKEN` organisation secret in a `url.insteadOf` rule). In\n  CI, `could not read Password for 'https://github.com'` means that secret was empty.\n"},{"component":"actionstools-app","module":"ROOT","path":"contributing/documentation.adoc","title":"Writing these docs","description":"How this Antora component is laid out, how to preview it, and the conventions to follow.","url":"https://docx.payability.com/actionstools-app/contributing/documentation.html","navOrder":27,"source":"= Writing these docs\n:navtitle: Writing these docs\n:description: How this Antora component is laid out, how to preview it, and the conventions to follow.\n\n`docs/` is an *Antora documentation component*, not a site. The central Payability documentation\nsite (the `pyb-docs` repository) lists it as a content source:\n\n[source,yaml]\n----\ncontent:\n  sources:\n    - url: https://github.com/Payability/ActionsToolsApp.git\n      branches: [release]\n      start_path: docs\n----\n\n`docs/antora.yml` declares the component `actionstools-app`, unversioned (`version: ~`), so the\nsite shows the state of `release`. Pages elsewhere link here with\n`+xref:actionstools-app::index.adoc[]+`.\n\nAttributes under `asciidoc.attributes` (`+{project-name}+`, `+{repo-url}+`, `+{repo-tree}+`,\n`+{python-version}+`, `+{prod-env}+`, `+{dev-aws-account}+`, `+{prod-aws-account}+`,\n`+{prod-stack}+`, `+{stage-stack}+`, `+{prod-url}+`) are soft-set with `@`; use them instead of\nrepeating values.\n\n== Layout\n\n[source]\n----\ndocs/\n├── antora.yml\n└── modules/ROOT/\n    ├── nav.adoc          every page must be listed here\n    ├── pages/            index, architecture, request-lifecycle, api/, async/, data/,\n    │                     components/, operations/, contributing/, reference/\n    └── examples/         JSON payloads, included as example$<name>.json\n----\n\n== Conventions\n\n* *One page, one subject*, with a level-0 title, `:navtitle:` and a one-sentence `:description:`.\n* *Cross-references* use `+xref:<path in pages>[]+`; anchors are generated with `idprefix: ''` and\n  `idseparator: '-'`, so `== Smoke test after a deploy` is `#smoke-test-after-a-deploy`.\n* *Source paths* are monospace (`tasks/services.py`), not links.\n* *Payloads* live in `examples/` and are included, never pasted twice.\n* *Tables* get `options=\"header\"` and explicit `cols=`; escape a literal `|` as `\\|`.\n* *Admonitions*: `NOTE` for context, `IMPORTANT` for a contract, `WARNING` for a foot-gun.\n* *No secrets* — not even expired ones. Use placeholders like `<token>`.\n\n== Previewing locally\n\n[source,yaml]\n----\n# /tmp/local-playbook.yml\nsite:\n  title: actionstools-app (local)\n  start_page: actionstools-app::index.adoc\ncontent:\n  sources:\n    - url: .            # run from the repository root\n      branches: HEAD\n      start_path: docs\nui:\n  bundle:\n    url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable\n    snapshot: true\n----\n\n[source,bash]\n----\nnpx antora --fetch /tmp/local-playbook.yml && open build/site/index.html\n----\n\nAntora reads committed content from git: commit (or use a worktree) before previewing.\n\n== Keeping the docs truthful\n\nUpdating the page is part of the change:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Change | Page\n\n| A new or changed route | xref:api/index.adoc[] route catalog and the resource page\n| A new async task or Worker event | xref:async/worker.adoc[]\n| Counter behaviour | xref:async/counters.adoc[]\n| A new table, index or attribute | xref:data/dynamodb.adoc[]\n| A new downstream service | a page under Integrations\n| A new setting or secret | xref:operations/configuration.adoc[], xref:reference/settings.adoc[]\n| `template.yaml`, `samconfig.toml`, CI | xref:architecture.adoc[], xref:operations/deployment.adoc[]\n| A new failure mode | xref:operations/troubleshooting.adoc[]\n| A new file worth finding | xref:reference/source-map.adoc[]\n|===\n"},{"component":"actionstools-app","module":"ROOT","path":"reference/settings.adoc","title":"Settings & secrets reference","description":"Every environment variable and secret key the service reads, where it is set, and its value per environment.","url":"https://docx.payability.com/actionstools-app/reference/settings.html","navOrder":28,"source":"= Settings & secrets reference\n:navtitle: Settings & secrets reference\n:description: Every environment variable and secret key the service reads, where it is set, and its value per environment.\n\nSource column: *SAM* = Lambda environment from the `Env` mapping of `template.yaml`; *Secret* =\n`actionstools/<env>`; *crm* = `common/crm`; *default* = fallback in code.\n\n== Non-secret\n\n[cols=\"3,1,3,3\",options=\"header\"]\n|===\n| Variable | Source | prod | stage\n\n| `ENV_NAME` | SAM | `production` | `staging`\n| `LOG_LEVEL` | SAM | `ERROR` | `WARNING`\n| `APP_SECRET_ID` | SAM | `actionstools/prod` | `actionstools/stage`\n| `AWS_SECRETS_MANAGER` | SAM | `True` | `True`\n| `FLASK_DEBUG_ENABLED` | SAM | `False` | `False`\n| `WORKER_FUNCTION_NAME` | SAM | `actionstools-prod-worker` | `actionstools-stage-worker`\n| `EVENT_LOGGER_APP_URL` | SAM | `https://api.payability.com/eventlogger` | `https://drkrpkdxe4.execute-api.us-east-1.amazonaws.com/stage`\n| `MESSAGING_APP_URL` | SAM | `https://api.payability.com/messaging` | `https://argumexh08.execute-api.us-east-1.amazonaws.com/stage`\n| `B2BDOCUMENT_API_URL` | SAM | `http://52.207.166.124:8080/b2bdocument/api` | same\n| `PAYABILITY_INTERNAL_API_URL` | SAM | `https://internal.payability.com/bin/internal/` | `https://staging.dev.payability.com/database_v2/internal/`\n| `AWS_REGION` | Lambda | `us-east-1` | `us-east-1`\n| `DYNAMODB_TIMEOUT` | default | `10` | `10`\n|===\n\n== Secret\n\n[cols=\"2,1,4\",options=\"header\"]\n|===\n| Key | Source | Used for\n\n| `SLACK_TOKEN` | Secret | Slack DMs — xref:components/slack.adoc[]. Required.\n| `SENTRY_DSN` | Secret | Error reporting — xref:components/sentry.adoc[].\n| `MESSAGING_API_KEY` | Secret | `MessagingAppClient` key for instance 8001. Required.\n| `EVENT_LOGGER_API_KEY` | Secret | `EventLoggerClient` key for instance 8001. Required.\n| `AUTH_JWT_RSA_PUBLIC_KEY` | Secret or crm | Must be set for every authenticated route (`pyb_flask_utils` asserts it), but tokens are validated by the internal API, not with this key.\n| `<instance id>` → token | crm | `Authorization: Token …` instance auth (`/settings/<config_key>/`).\n|===\n\n== Code constants (`config.py`)\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Name | Value\n\n| `BASE_PREFIX` | `/actionstools` when `ENV_NAME=production`, else empty\n| `DEFAULT_INSTANCE_ID` | `8001`\n| `GCI_ID` | `1` (B2B documents)\n| `INT_SUPER_USER` | `CRM_ADMIN`\n| `TEMPLATES_FOLDER_SETTINGS_KEY` | `template_folders`\n| `NOTIFICATIONS_EMAIL_BLACKLIST` | addresses never notified\n| `RESTX_*`, `SWAGGER_UI_DOC_EXPANSION` | flask-restx behaviour\n|===\n\n== Local-only\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Variable | Meaning\n\n| `AWS_SECRETS_MANAGER=False` | Skip both secrets; read everything from the environment.\n| `AWS_PROFILE` | Credentials for DynamoDB / Secrets Manager.\n| `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY` | With `ENV_NAME=local`, explicit DynamoDB credentials for flask-dynamo.\n| `X_SESSION_TOKEN` | Read by `scripts/*.py`.\n|===\n"},{"component":"actionstools-app","module":"ROOT","path":"reference/source-map.adoc","title":"Source map","description":"Where to find things in the repository.","url":"https://docx.payability.com/actionstools-app/reference/source-map.html","navOrder":29,"source":"= Source map\n:navtitle: Source map\n:description: Where to find things in the repository.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Path | What lives there\n\n| `lambda_handlers.py` | `api_handler` (apig-wsgi) and `worker_handler` (async tasks, stream, schedule).\n| `secrets_loader.py` | Secrets Manager → `os.environ`, run on import.\n| `config.py` | Flask config; every setting and required-variable assertion.\n| `run.py` | `app = create_app(\"config\")`, Sentry, root debug route.\n| `run_locally.py` | Local server on :5555 (gevent).\n| `actionstools_app/__init__.py` | Flask app, CORS, table names and definitions, flask-dynamo, `create_app`, `/setup-install`.\n| `actionstools_app/api.py` | flask-restx `PybApi`, `ApiResource`, `ApiResourceWithAPiKeys`, shared header parsers.\n| `actionstools_app/notes/` | xref:api/notes.adoc[]\n| `actionstools_app/tasks/views.py`, `services.py` | xref:api/tasks.adoc[]\n| `actionstools_app/tasks/notifications.py` | `SlackNotifier`, email and Slack templates, `notify_on_slack`.\n| `actionstools_app/comments/` | xref:api/comments.adoc[]; `notify.py` queues comment notifications.\n| `actionstools_app/files/views.py` | xref:api/files.adoc[]\n| `actionstools_app/settings/` | xref:api/settings.adoc[]; `configs.py` holds the per-type validators.\n| `actionstools_app/notifications/` | xref:api/notifications.adoc[]\n| `actionstools_app/activity/` | xref:api/activity.adoc[]; `services.py` reads event logger and messaging.\n| `actionstools_app/reports/` | xref:api/reports.adoc[]\n| `actionstools_app/ddb_counts/` | xref:async/counters.adoc[]: stream handler, rules, counter utilities.\n| `actionstools_app/tools/async_task.py` | `@async_task` — xref:async/worker.adoc[]\n| `actionstools_app/tools/async_action.py` | `with_error_handler`, `handle_async_errors`.\n| `actionstools_app/tools/utils.py` | `CRMCrudService`, permission validators, `get_user`, `setup_sentry`, DynamoDB (de)serialisers.\n| `actionstools_app/transport/http.py` | Thin `requests` wrapper used by the activity services.\n| `tests/` | unittest suite; `__init__.py` holds the offline environment, `utils.py` the moto base class.\n| `template.yaml` | SAM template and the per-environment `Env` mapping — xref:architecture.adoc[]\n| `samconfig.toml` | Stack name, region and `EnvName` per environment — xref:operations/deployment.adoc[]\n| `pyproject.toml`, `uv.lock`, `.python-version` | uv project — xref:operations/local-development.adoc[]\n| `Makefile` | Developer and CI commands (`make help`).\n| `.github/workflows/test.yml`, `deploy.yml` | CI/CD — xref:operations/deployment.adoc#ci[].\n| `.env.example` | Template for the local `.env` read by `make run`.\n| `scripts/` | One-off maintenance scripts (need `X_SESSION_TOKEN`).\n| `examples/notes_api.py` | API client walkthrough.\n| `docs/` | This component — xref:contributing/documentation.adoc[]\n|===\n"},{"component":"actionstools-app","module":"ROOT","path":"reference/glossary.adoc","title":"Glossary","description":"Terms used in the code and these docs.","url":"https://docx.payability.com/actionstools-app/reference/glossary.html","navOrder":30,"source":"= Glossary\n:navtitle: Glossary\n:description: Terms used in the code and these docs.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Term | Meaning\n\n| Target | The entity an item belongs to, identified by `target_uuid` (`X-Actions-TargetUUID`) — usually a supplier, or a task/note/comment for comments.\n| Target type | `target_type`, the kind of target (`supplier`, `suppliers-payment`, `note`, `task`, `upload-file`, …).\n| Owner | `owner_uuid` — the user who created an item; only the owner or a `CRM_ADMIN` may change owner-only fields.\n| Instance | A SaaS tenant (`instance_id`). Payability is `8001`; global settings use `1`.\n| IA | Internal Admin, the CRM front end (`internal.payability.com`).\n| `login_id` / `login_key` | The caller's email / user uuid, from the JWT.\n| Async task | A function decorated with `@async_task`, run on the Worker Lambda.\n| Worker | `actionstools-<env>-worker`, the Lambda for async tasks, the stream and the schedule.\n| Counter | A cached count of tasks matching one filter list, in `em_actions_tasks_counters`.\n| `item_number` | Human-readable task number from the global sequence in `em_actions_counters`.\n| Soft delete | Setting `deleted_at` instead of removing the item.\n| `BASE_PREFIX` | URL prefix of every route: `/actionstools` in production.\n| `common/crm` | Shared Secrets Manager secret with instance tokens and the JWT public key.\n| Zappa | The previous deployment tool; `prod` / `prod2` / `stage` were its Lambdas.\n|===\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"index.adoc","title":"Automatic Underwriting Tool","description":"What the Automatic Underwriting Tool does, how it is put together, and where to read next.","url":"https://docx.payability.com/automatic-uw-tool/index.html","navOrder":0,"source":"= Automatic Underwriting Tool\n:navtitle: Overview\n:description: What the Automatic Underwriting Tool does, how it is put together, and where to read next.\n\n{project-name} automates the identity-verification and background-check steps of Payability's\nsupplier underwriting (KYC / KYB). It orchestrates external data vendors behind a uniform\n*process* abstraction, runs the slow vendor calls asynchronously on AWS Simple Workflow (SWF),\npersists results to DynamoDB and S3, and notifies the rest of the platform through EventBridge.\n\ninclude::partial$golden-rule.adoc[]\n\n== What it does\n\nGiven a *supplier* and a *person* (a supplier owner), the tool runs one of four underwriting\nprocesses:\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Process | Vendor | Purpose\n\n| `validate_identity_document`\n| Au10tix / Entrust\n| Verify that a government ID is authentic, unexpired, good quality, and matches the owner on record.\n\n| `validate_personal_information`\n| Thomson Reuters CLEAR (EIDV)\n| KYC — confirm the owner's submitted PII matches vendor records, field by field.\n\n| `download_personal_background_report`\n| Thomson Reuters CLEAR\n| Criminal / bankruptcy / lien / lawsuit report, scored into `access` and `advance` verdicts.\n\n| `download_business_background_report`\n| Thomson Reuters CLEAR\n| Business-entity background report, archived as raw data.\n|===\n\nEvery process is a two-step pipeline: a synchronous *request* step served by the Web API, and\nan asynchronous *handle* step run by the SWF worker. The Entrust identity-verification flow is\nthe one deliberate exception — its request step is a dedicated endpoint and its handle step is\ntriggered by a vendor webhook. See xref:domain-model.adoc[] and xref:processes/index.adoc[].\n\n== The 30-second model\n\n* *Two containers, one repository.* The Web API (`web_api_app.py`) and the SWF worker\n  (`swf_app.py`) are built from the same code and deployed as separate ECS services.\n* *Four layers.* `interface/` (HTTP) → `task/` (domain) → `components/` (integrations) →\n  `config/` + `utils/` (cross-cutting).\n* *One join key.* A `correlation_id` ties the request step, the SWF payload, every persisted\n  row, the S3 object, and the outbound events for a single execution.\n* *Config is Dynaconf.* `settings_setup.py` generates non-secret settings, `config_setup.py`\n  pulls secrets from AWS Secrets Manager; app code only reads `config.config.settings`.\n\n[source]\n----\n                 x-session-token\n   Client  ─────────────────────────►  Web API (FastAPI, ECS)\n                                              │  1. persist task (DynamoDB)\n                                              │  2. set tool PENDING\n                                              │  3. start workflow ──► B2B Task Manager ──► AWS SWF\n                                              ▼\n                                        {correlation_id}\n\n   AWS SWF  ──poll activity task──►  SWF Worker (swf_app.py, ECS)\n                                              │  call Au10tix / Entrust / Thomson Reuters\n                                              │  write raw result to S3\n                                              │  update final-status + tool tables\n                                              ▼\n                                        EventBridge (process finished, TR match / mismatch)\n----\n\n== Where to read next\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| If you want… | Read\n\n| The big picture, containers, layers\n| xref:architecture.adoc[]\n\n| How one request flows end to end\n| xref:request-lifecycle.adoc[]\n\n| The domain: processes, tasks, factories, failure handling\n| xref:domain-model.adoc[]\n\n| What each process actually does, rule by rule\n| xref:processes/index.adoc[]\n\n| Every HTTP endpoint, its auth and its payloads\n| xref:api/index.adoc[]\n\n| SWF orchestration and the outbound event catalog\n| xref:events/swf-orchestration.adoc[] · xref:events/eventbridge-events.adoc[]\n\n| Table shapes, keys, S3 key conventions\n| xref:data/dynamodb.adoc[] · xref:data/s3-datalake.adoc[]\n\n| Vendor, AWS and internal clients\n| xref:components/index.adoc[]\n\n| Running it locally, configuring it, shipping it\n| xref:operations/local-development.adoc[] · xref:operations/configuration.adoc[] · xref:operations/deployment.adoc[]\n\n| The rules new code must follow\n| xref:contributing/python-standards.adoc[] · xref:contributing/testing.adoc[]\n\n| A term you do not recognise\n| xref:reference/glossary.adoc[]\n|===\n\n== Repository layout\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Path | Contents\n\n| `web_api_app.py` | FastAPI application entry point (Web API container).\n| `swf_app.py` | SWF activity-worker entry point (worker container).\n| `task/` | Domain layer: processes and tasks — xref:domain-model.adoc[].\n| `components/` | Integration layer: vendor, AWS and internal clients — xref:components/index.adoc[].\n| `interface/web_api/` | Routers, endpoint handlers, pydantic schemas — xref:api/index.adoc[].\n| `config/` | Dynaconf entry point plus the generated `settings.json` / `.secrets.json`.\n| `utils/` | Small helpers (dict, string, time, metric, task utilities).\n| `development/` | `unit_tests/`, `integration_tests/`, `e2e_tests/`.\n| `deployment/` | Dockerfiles and `docker-compose.yml`.\n| `artifacts/` | ECS `taskdef.json` and CodeDeploy `appspec.yaml`.\n| `examples/` | Sample vendor responses used by tests and test mode.\n| `buildspec.yml` | AWS CodeBuild pipeline.\n| `settings_setup.py`, `config_setup.py` | Settings and secrets bootstrap.\n| `docs/` | This Antora documentation component.\n|===\n\n== Tech stack\n\nPython {python-version} · FastAPI · boto3 · Dynaconf · AWS (SWF, DynamoDB, S3, EventBridge,\nECS, CodeBuild/CodeDeploy, Secrets Manager, CloudWatch) · Sentry · `pyb-sdk` (internal) ·\n`hubspot-api-client`.\n\nApplication version: `1.0.0` (`version.py`, also stamped onto every changelog row).\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"architecture.adoc","title":"System architecture","description":"The two containers, the four layers, and the cross-cutting concerns.","url":"https://docx.payability.com/automatic-uw-tool/architecture.html","navOrder":1,"source":"= System architecture\n:navtitle: System architecture\n:description: The two containers, the four layers, and the cross-cutting concerns.\n\nHow {project-name} is structured. For the step-by-step flow of a single request see\nxref:request-lifecycle.adoc[].\n\n== Two containers, one codebase\n\n[cols=\"1,1,2,1\",options=\"header\"]\n|===\n| Container | Entry point | Role | Scaling\n\n| *Web API*\n| `web_api_app.py`\n| Synchronous orchestrator. Authenticates callers, validates input, persists the request, starts async work, serves status and results.\n| ECS service behind an ALB\n\n| *SWF worker*\n| `swf_app.py`\n| Asynchronous executor. Long-polls AWS SWF, performs the vendor calls, writes results and emits events.\n| ECS service, `SWF_APP_THREADS` (5) polling threads per task\n|===\n\ninclude::partial$golden-rule.adoc[]\n\n== Layers\n\n[source]\n----\ninterface/        HTTP layer — FastAPI routers + endpoint handlers   (Web API only)\n   └── web_api/\ntask/             Domain layer — processes & tasks (the business logic)\ncomponents/       Integration layer — vendor, AWS and internal-service clients\nconfig/ utils/    Cross-cutting — Dynaconf configuration and helpers\n----\n\n`interface/` depends on `task/` and `components/`, and stays thin: parse the request, check\nauth and permissions, delegate, shape the response. `task/` is the heart of the system and\ndepends on `components/` for *all* I/O — no `requests` or `boto3` import belongs under\n`task/`. `components/` wraps everything external.\n\n== Component diagram\n\n[source,mermaid]\n----\nflowchart TB\n    Client([Client: internal admin, supplier dashboard, onboarding])\n    Onfido([Onfido / Entrust webhook])\n\n    subgraph WebAPI[\"Web API container (web_api_app.py)\"]\n        Router[FastAPI routers]\n        JWT[JWTAuthorizer]\n        Handlers[Endpoint handlers]\n    end\n\n    subgraph Worker[\"SWF worker container (swf_app.py)\"]\n        Loop[poll loop x5 threads]\n        TF[TaskFactory]\n        Handle[Handle tasks]\n    end\n\n    subgraph AWS\n        SWF[(AWS SWF)]\n        DDB[(DynamoDB)]\n        S3[(S3 datalake)]\n        EB[(EventBridge)]\n        CW[(CloudWatch metrics)]\n    end\n\n    subgraph Vendors[\"External services\"]\n        AU[Au10tix]\n        ENT[Entrust / Onfido]\n        TR[Thomson Reuters CLEAR]\n        PAY[Payability Internal API]\n        DOCS[PYB Documents]\n        HS[HubSpot CRM]\n        EL[Internal EventLogger]\n        B2B[B2B Task Manager]\n    end\n\n    Client -->|x-session-token| Router --> JWT --> Handlers\n    Onfido -->|X-SHA2-Signature| Router\n    JWT --> PAY\n    Handlers -->|start workflow| B2B --> SWF\n    Handlers --> DDB\n    Handlers --> S3\n    Handlers --> CW\n    Handlers --> ENT\n    Handlers --> HS\n    Handlers --> EL\n\n    SWF -->|activity task| Loop --> TF --> Handle\n    Handle --> AU\n    Handle --> ENT\n    Handle --> TR\n    Handle --> DOCS\n    Handle --> PAY\n    Handle --> HS\n    Handle --> EL\n    Handle --> DDB\n    Handle --> S3\n    Handle --> EB\n    Handle -->|success / failure| SWF\n----\n\n[NOTE]\n====\nThe Web API's only outbound vendor calls are the Entrust session creation\n(`POST /entrust/sessions` must mint the Onfido applicant and workflow run before it can\nreturn a URL) and the best-effort HubSpot / supplier-event syncs. Both are fast, single-shot calls;\neverything slow still happens on the worker.\n====\n\n== Why SWF, and why through the B2B Task Manager\n\n* Vendor calls are inherently asynchronous: Au10tix and Thomson Reuters both use\n  submit → poll-for-readiness → fetch-result, and Entrust notifies by webhook minutes later.\n  SWF gives durable, retryable orchestration without the Web API holding a connection open.\n* The Web API does not call SWF directly. It calls the shared *B2B Task Manager* HTTP service\n  (`components/b2b_task_manager.py`), which performs `StartWorkflowExecution`. The workflow\n  definition itself is owned outside this repository; it schedules an *activity* onto the task\n  list `{swf-task-list}`, which this repository's worker polls.\n\nSWF coordinates (from `settings_setup.py`):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Setting | Value\n\n| `SWF_WORKFLOW_TYPE` | `Update Amazon SC Earnings`\n| `SWF_WORKFLOW_TYPE_VERSION` | `1.1`\n| `SWF_DOMAIN` | `B2B` in `{prod-env}`, the environment name in dev\n| `SWF_AUTOMATIC_UW_TOOL_TASK_LIST` | `{swf-task-list}`\n| `SWF_APP_THREADS` | `5`\n|===\n\nDetails in xref:events/swf-orchestration.adoc[].\n\n== Persistence and observability\n\n* *DynamoDB* — three purpose-built tables plus an append-only changelog:\n  xref:data/dynamodb.adoc[].\n* *S3 datalake* — raw vendor responses as JSON, partitioned by supplier and person:\n  xref:data/s3-datalake.adoc[].\n* *EventBridge* — downstream notifications: xref:events/eventbridge-events.adoc[].\n* *CloudWatch* — per-process and per-endpoint timing/failure metrics via `pyb-sdk`\n  (`utils/metric_utils.py`).\n* *Sentry* — exception capture in both containers (`sentry_sdk.init` in each entry point).\n\nSee xref:operations/observability.adoc[] for the full metric and log inventory.\n\n== Cross-cutting concerns\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Concern | Where | Notes\n\n| Authentication\n| `components/auth/jwt_authorizer.py`\n| `x-session-token` header validated against the Payability auth API; the JWT is then decoded without signature verification to read `login_key`.\n\n| Authorization\n| `components/permissions/permissions.py`\n| Internal callers need a perm (`DEV`, `FINANCE`, `REST_UW_ACCESS`, …); supplier-dashboard callers skip perm checks but are scoped to their own `supplier_key`.\n\n| Webhook trust\n| `interface/web_api/endpoint_handling/handle_entrust_webhook.py`\n| Onfido cannot present a Payability JWT, so the callback is verified by an `X-SHA2-Signature` HMAC instead.\n\n| Configuration\n| `config/config.py` (Dynaconf)\n| Loaded from the generated `settings.json` + `.secrets.json`: xref:operations/configuration.adoc[].\n\n| Failure handling\n| `utils/task_utils.py` — `task_failure_handling`\n| Context manager wrapping task logic; on error marks the task `failed`, flips the tool to `ERROR`, and re-raises so SWF and Sentry see it.\n\n| Retry / polling\n| `components/retry/retry.py`\n| Generic poll-until-ready used for vendor readiness checks. The Entrust client additionally retries 429/5xx at the HTTP-adapter level.\n\n| Data deletion\n| `components/s3_purgable/`\n| Handle tasks that archive to S3 implement `PurgableS3ObjectProducer.purge()` for GDPR-style deletion, driven by `DELETE /tasks/result`.\n|===\n\n== The correlation id\n\ninclude::partial$correlation-id.adoc[]\n\n== Directory reference\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Path | Layer | Documented in\n\n| `web_api_app.py`, `interface/web_api/` | HTTP | xref:api/index.adoc[]\n| `swf_app.py` | Worker entry | xref:events/swf-orchestration.adoc[]\n| `task/` | Domain | xref:domain-model.adoc[]\n| `components/` | Integration | xref:components/index.adoc[]\n| `config/`, `settings_setup.py`, `config_setup.py` | Configuration | xref:operations/configuration.adoc[]\n| `deployment/`, `artifacts/`, `buildspec.yml` | Delivery | xref:operations/deployment.adoc[]\n|===\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"request-lifecycle.adoc","title":"Request lifecycle","description":"End-to-end traces of the two shapes of execution: process-driven and webhook-driven.","url":"https://docx.payability.com/automatic-uw-tool/request-lifecycle.html","navOrder":2,"source":"= Request lifecycle\n:navtitle: Request lifecycle\n:description: End-to-end traces of the two shapes of execution: process-driven and webhook-driven.\n\nTwo shapes of execution exist. The *process-driven* shape covers three of the four processes\nplus Au10tix identity documents; the *webhook-driven* shape covers Entrust identity\nverification.\n\n== Process-driven: `POST /tasks/execute`\n\n[source,mermaid]\n----\nsequenceDiagram\n    participant C as Client\n    participant API as Web API\n    participant DDB as DynamoDB\n    participant B2B as B2B Task Manager\n    participant SWF as AWS SWF\n    participant W as SWF Worker\n    participant V as Vendor (Au10tix / TR)\n    participant EB as EventBridge\n\n    C->>API: POST /tasks/execute {task_name, task_input}\n    API->>API: JWT auth, permissions, source scoping, dedup, rate limit\n    API->>DDB: TaskExecutionTable.save (status=pending)\n    API->>DDB: ToolTable.update (status=PENDING)\n    API->>B2B: start_swf(task_name=handle_*, correlation_id, input)\n    B2B->>SWF: StartWorkflowExecution\n    API-->>C: 200 {correlation_id}\n\n    Note over SWF,W: later, asynchronously\n    W->>SWF: poll_for_activity_task(task_list=automatic-uw-tool-task-list)\n    SWF-->>W: activity {task_name=handle_*, task_input}\n    W->>DDB: load the full input by correlation_id\n    W->>V: vendor call (submit, poll readiness, fetch result)\n    V-->>W: raw result\n    W->>S3: archive raw result JSON\n    W->>DDB: task -> processed; FinalStatusTable + ToolTable written\n    W->>EB: ProcessFinishedEvent (+ TR match / mismatch events)\n    W->>SWF: RespondActivityTaskCompleted\n----\n\nThe caller then polls xref:api/tasks.adoc#get-task-status[`GET /tasks/\\{correlation_id}`] for\nstatus and xref:api/tasks.adoc#get-task-result[`GET /tasks/result`] for the outcome.\n\n=== Step by step\n\n. *Authenticate.* `JWTAuthorizer` validates `x-session-token` against the Payability auth API\n  and resolves `login_id`, `login_key`, `source` and optional `supplier_key`.\n. *Authorize.* Internal callers must hold `DEV`, `FINANCE` or `INT_SUPER_USER`;\n  supplier-dashboard callers skip the perm check.\n. *Resolve the process.* `ProcessFactory.identify_process_name` accepts either a process name\n  or a task-step name.\n. *Guardrails.* Supplier-dashboard requests must target their own `supplier_key`; an identical\n  non-failed in-flight execution short-circuits to the existing `correlation_id`; the\n  supplier dashboard is capped at 3 executions per supplier + process; `force_new_execution`\n  bypasses dedup for internal callers only.\n. *Source allow-list.* The task's `get_available_process_sources()` decides which\n  `ProcessSource` values may start it.\n. *Run the request task.* `ProcessFactory.build()` returns both steps, and the handler executes\n  only the first. That step persists the row, sets the tool `PENDING`, validates input if the\n  process has a validator, and starts the SWF workflow for step two.\n. *Handle step.* The worker builds the task through `TaskFactory`, reloads the input (by\n  `correlation_id`, or straight from the SWF payload for Au10tix), performs the vendor calls,\n  archives raw JSON to S3, writes verdicts, and emits events.\n\n== Webhook-driven: Entrust identity verification\n\n[source,mermaid]\n----\nsequenceDiagram\n    participant S as Supplier\n    participant API as Web API\n    participant ONF as Entrust / Onfido\n    participant DDB as DynamoDB\n    participant HS as HubSpot\n    participant B2B as B2B Task Manager\n    participant W as SWF Worker\n    participant INT as Payability Internal API\n\n    S->>API: POST /entrust/sessions {supplier_key, person_id}\n    API->>INT: get_supplier_info (owner first/last name)\n    API->>ONF: create applicant, create workflow run\n    ONF-->>API: {id, sdk_token, link:{url, expires_at}}\n    API->>DDB: TaskExecutionTable.save (task_id = workflow_run_id, pending)\n    API->>DDB: ToolTable.update (ENTRUST, PENDING)\n    API->>HS: entrust_verification_status = in_progress (best effort)\n    API-->>S: 200 {url, workflow_run_id, sdk_token, link_expires_at}\n\n    S->>ONF: completes the hosted verification flow\n    ONF->>API: POST /entrust/webhook (workflow_run.completed, HMAC signed)\n    API->>API: verify X-SHA2-Signature, filter event type\n    API->>DDB: TaskExecutionTable.get(workflow_run_id)\n    API->>B2B: start_swf(entrust/handle_identity_document_validation_response)\n    API-->>ONF: 200 {\"status\": \"accepted\"}\n\n    W->>ONF: get_workflow_run + hydrate step tasks\n    W->>S3: archive full run JSON\n    W->>INT: archive stale ID captures, upload documents, then liveness media\n    W->>DDB: FinalStatusTable + ToolTable + task processed\n    W->>INT: approve uploaded documents (approved verdict only)\n    W->>HS: verified / in_review / denied (best effort)\n    W->>EB: ProcessFinishedEvent\n----\n\nThe webhook stays deliberately thin — verify, correlate, enqueue — because the aftermath\n(Onfido re-read, S3 archive, document download and re-upload) is far too slow to run inside\na webhook request without risking a delivery timeout. Full detail in\nxref:processes/validate-identity-document-entrust.adoc[].\n\n== Inside an endpoint handler\n\n[source]\n----\nFastAPI route\n  └─ JWTAuthorizer dependency          → 400 / 401 / 404 / 409 / 500 on a bad token\n       └─ Handler.handle()\n            ├─ WebRequestLogger.log(login_id, source, url, body)\n            ├─ check_permissions()      (where applicable)\n            ├─ validate input / scope the source\n            ├─ business logic (DynamoDB, S3, B2B, vendor client)\n            ├─ send_metric()            → CloudWatch via pyb-sdk\n            └─ return a pydantic model | JSONResponse\n----\n\n* `BaseEndpointHandler` (`interface/web_api/endpoint_handling/base.py`) provides\n  `check_permissions()`, the `StopExecution` control-flow exception for an early controlled\n  exit, `build_json_response()` (a `{code, status, message}` envelope) and the metrics\n  namespace.\n* `WebRequestLogger` logs `[login_id:source] /path (payload)` at INFO.\n* Several endpoints accept an untyped `dict = Body()` and validate manually in the handler.\n  New endpoints should use pydantic models instead — see xref:contributing/python-standards.adoc[].\n\n== Inside the worker loop\n\nEach of the five threads runs the same loop (`swf_app.py`):\n\n. Long-poll `poll_for_activity_task` on `{swf-task-list}` (read timeouts and closed\n  connections simply retry).\n. Parse `activity_task['input']` into a task definition, build the task via `TaskFactory`,\n  deserialize `task_input`, and `execute()` it.\n. On success: `RespondActivityTaskCompleted` with `output.to_dict()`, plus a\n  `\\{process_name}_success_time_taken` metric.\n. On any exception: capture to Sentry, `RespondActivityTaskFailed` (short `reason`, full\n  payload in `details`), plus a `\\{process_name}_failure` metric.\n\nBecause `task_failure_handling` already marked the task `failed` and the tool `ERROR`, an SWF\nretry finds consistent state.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"domain-model.adoc","title":"Domain model: processes & tasks","description":"The task framework, the two factories, task inputs and outputs, and failure handling.","url":"https://docx.payability.com/automatic-uw-tool/domain-model.html","navOrder":3,"source":"= Domain model: processes & tasks\n:navtitle: Domain model\n:description: The task framework, the two factories, task inputs and outputs, and failure handling.\n\nEverything under `task/` is the domain core. This page describes the *framework*; what each\nprocess does is in xref:processes/index.adoc[].\n\n== Two concepts\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concept | Definition\n\n| *Process*\n| A named business workflow with exactly two ordered steps: a *request* task and a *handle-response* task. Enumerated in `task/process.py`.\n\n| *Task*\n| A single executable unit with a unique `task_name`, e.g. `thomson_reuters/request_personal_information_validation`.\n\n| *Initial (request) task*\n| Step 1. Runs synchronously inside the Web API. Persists the input, sets the tool `PENDING`, validates, and starts the SWF workflow for step 2. Returns the `correlation_id`.\n\n| *Handle task*\n| Step 2. Runs asynchronously in the SWF worker. Reloads the input, calls the vendor, archives raw output to S3, writes verdicts, emits events.\n|===\n\nThe four processes (`task/process.py`):\n\n[source,python]\n----\nclass Process:\n\n    VALIDATE_PERSONAL_INFORMATION = 'validate_personal_information'\n    DOWNLOAD_PERSONAL_BACKGROUND_REPORT = 'download_personal_background_report'\n    DOWNLOAD_BUSINESS_BACKGROUND_REPORT = 'download_business_background_report'\n    VALIDATE_IDENTITY_DOCUMENT = 'validate_identity_document'\n----\n\n== Task package layout\n\nEvery task lives in its own package with a fixed file layout. Match it exactly when adding one.\n\n[source]\n----\ntask/<vendor>/<task_name>/\n├── __init__.py           # re-exports TaskImplementation / TaskInput / TaskOutput (+ validator)\n├── implementation.py     # class TaskImplementation  — the executable logic\n├── input.py              # class TaskInput           — typed input + from_dict/to_dict\n├── output.py             # class TaskOutput          — typed output + to_dict\n└── input_validator.py    # request tasks only: TaskInputValidator, raises ValidationError\n----\n\n== The `GenericTask` contract\n\n[source,python]\n----\nclass GenericTask:\n\n    class Input:\n        def to_dict(self) -> dict: ...\n        @staticmethod\n        def from_dict(d: dict): ...\n\n    class Output:\n        def to_dict(self): ...\n\n    def __init__(self, correlation_id: str, logger: Optional[Logger] = None): ...\n\n    def execute(self, input_: Input) -> Output: ...\n\n    @staticmethod\n    def build_task_input(d: dict): ...\n    @staticmethod\n    def get_task_name(): ...\n    @staticmethod\n    def get_process_name(): ...\n\n    def _update_task_failure_in_task_execution_table(self, reason): ...\n----\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Method | Purpose\n\n| `build_task_input(d)` | Deserialize a dict (HTTP body or SWF payload) into a typed `Input`.\n| `execute(input_)` | Run the business logic and return a typed `Output`.\n| `get_task_name()` | Unique id used for `TaskFactory` dispatch and process mapping.\n| `get_process_name()` | Parent process constant — drives metrics, DynamoDB keys and events.\n| `_update_task_failure_in_task_execution_table(reason)` | Mark the task `failed` (and usually the tool `ERROR`); invoked by the failure handler.\n|===\n\n=== `InitialTask` adds\n\n[source,python]\n----\nclass InitialTask(GenericTask):\n    def __init__(self, correlation_id, process_source, requested_by=None, logger=None): ...\n\n    def __save_task_in_task_execution_table(self, input_): ...\n\n    @staticmethod\n    def get_available_process_sources() -> Tuple[ProcessSource, ...]: ...\n----\n\n* `process_source` — who triggered it: `INTERNAL`, `SUPPLIER_DASHBOARD` or `ONBOARDING`\n  (`components/process_source.py`).\n* `requested_by` — the caller's login id, persisted on the task row.\n* `get_available_process_sources()` — the allow-list enforced by `POST /tasks/execute`.\n\n=== `TaskExecutionError`\n\nA structured validation error serialized straight into API responses as\n`{\"error_class\": ..., \"error_body\": ...}`. `ValidationError` (input validators) and\n`CannotRunWorkflow` (B2B Task Manager) both subclass it, which is why a rejected input and an\n\"already running\" workflow both surface as clean `400` responses.\n\n== Factories\n\n=== `TaskFactory` (`task/task_factory.py`)\n\nBuilt once at worker startup with every shared client injected, then\n`build(task_definition: dict)` dispatches on `task_name`:\n\n[source,json]\n----\ninclude::example$task-definition.json[]\n----\n\nUnknown `task_name` raises `TaskNotSupported`. Injected components (see `swf_app.py`):\n`task_execution_table`, `final_status_table`, `tool_table`, `thomson_reuters_client`,\n`au10tix_client`, `entrust_client`, `hubspot_client`, `supplier_event_logger`,\n`b2b_task_manager`, `b2b_documents`,\n`s3_client`, `event_bridge_client`, `retry_client`, `payability_internal_api`.\n\nThe Web API builds its own `TaskFactory` per request inside the handlers that need one\n(`execute_task.py`, `get_task_result.py`, `delete_task_results.py`).\n\n=== `ProcessFactory` (`task/process_factory.py`)\n\n* `identify_process_name(s)` — accepts a process name *or* a task name and resolves the process\n  (case-insensitive); raises `ProcessNotSupported` otherwise.\n* `build(process_name, correlation_id=None, process_source=None, requested_by=None)` — returns\n  both task instances for the process, sharing one `correlation_id`. `POST /tasks/execute`\n  executes only the first; `DELETE /tasks/result` walks both looking for\n  `PurgableS3ObjectProducer` implementations.\n\n`PREDEFINED_PROCESS_STEPS` maps each process to its two step names:\n\n[cols=\"2,3,3\",options=\"header\"]\n|===\n| Process | Step 1 (request) | Step 2 (handle)\n\n| `validate_personal_information`\n| `thomson_reuters/request_personal_information_validation`\n| `thomson_reuters/handle_personal_information_validation_response`\n\n| `download_personal_background_report`\n| `thomson_reuters/request_personal_background_report`\n| `thomson_reuters/handle_personal_background_report_response`\n\n| `download_business_background_report`\n| `thomson_reuters/request_business_background_report`\n| `thomson_reuters/handle_business_background_report_response`\n\n| `validate_identity_document`\n| `au10tix/request_identity_document_validation`\n| `au10tix/handle_identity_document_validation_response`\n|===\n\n[IMPORTANT]\n====\n`entrust/handle_identity_document_validation_response` is registered in `TaskFactory` but is\ndeliberately *absent* from `PREDEFINED_PROCESS_STEPS`: it has no synchronous request step, and\nis fired by the Onfido webhook instead. `POST /tasks/execute` special-cases that task name and\ntreats it as an admin \"reprocess the latest Entrust run\" action — see\nxref:api/tasks.adoc#entrust-reprocess[].\n====\n\n== Task inputs and outputs\n\n[cols=\"3,2,2\",options=\"header\"]\n|===\n| Task | Input fields | Output\n\n| `au10tix/request_identity_document_validation`\n| `supplier_key`, `person_id`, `document_id`\n| `\\{correlation_id}`\n\n| `au10tix/handle_identity_document_validation_response`\n| `supplier_key`, `person_id`, `document_id` (from the SWF payload)\n| `\\{raw_result_s3_path}`\n\n| `entrust/handle_identity_document_validation_response`\n| `supplier_key`, `person_id`, `workflow_run_id`, optional `applicant_id`\n| `{raw_result_s3_path, status, verdict?, document_actions?}`\n\n| `thomson_reuters/request_personal_information_validation`\n| `supplier_key`, `person_id`, `first_name`, `last_name`, `birth_day`, `birth_month`, `birth_year`, `ssn`, `street`, `city`, `state`, `zip_code`\n| `\\{correlation_id}`\n\n| `thomson_reuters/handle_personal_information_validation_response`\n| `correlation_id`\n| `\\{match_result}`\n\n| `thomson_reuters/request_personal_background_report`\n| `supplier_key`, `person_id`, `first_name`, `last_name`, `ssn`, optional `test`, `test_timeout`\n| `\\{correlation_id}`\n\n| `thomson_reuters/handle_personal_background_report_response`\n| `correlation_id`\n| `\\{raw_result_s3_path}`\n\n| `thomson_reuters/request_business_background_report`\n| `supplier_key`, `person_id`, `business_name`, `state`\n| `\\{correlation_id}`\n\n| `thomson_reuters/handle_business_background_report_response`\n| `correlation_id`\n| `\\{raw_result_s3_path}`\n|===\n\n`TaskInput.from_dict` runs `remove_empty_values` first, so empty strings and nulls are treated\nas absent — a missing required field raises `KeyError`, which the API maps to a `400` naming\nthe field.\n\n== Failure handling\n\nEvery side-effecting `execute()` body runs inside this context manager\n(`utils/task_utils.py`):\n\n[source,python]\n----\n@contextmanager\ndef task_failure_handling(task_instance: GenericTask):\n    try:\n        yield\n    except CannotRunWorkflow:\n        raise\n    except Exception as e:\n        if task_instance.logger:\n            task_instance.logger.error(msg=f'Task has been failed with error - {str(e)}')\n        task_instance._update_task_failure_in_task_execution_table({'error': str(e)})\n        raise\n----\n\nOn any error except `CannotRunWorkflow` (which propagates cleanly so the API can answer\n`400`/`409`): log it, mark the task `failed` in `TaskExecutionTable`, flip the tool to `ERROR`\nwith the error message, then re-raise so the worker reports activity failure and Sentry\ncaptures it.\n\n=== Domain exceptions (`task/exceptions/request.py`)\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Exception | Meaning\n\n| `ResultNotFound` | The vendor returned no matching records (e.g. a Thomson Reuters search `GroupCount` of 0).\n| `MalformedResponse` | An expected XML/JSON node was missing from the vendor response.\n| `MissingResponseSection` | A personal background report is missing a required section.\n|===\n\n`components/retry/exceptions.py` adds `RetryException` (a `ConnectionError`) for an exhausted\npoll loop, and `components/pyb_documents.py` raises `DocumentNotFound` for a 404 from the\ndocument service.\n\n== Adding a process\n\n. Add the constant to `task/process.py`.\n. Create `task/<vendor>/request_<name>/` and `task/<vendor>/handle_<name>_response/`, each with\n  `implementation.py`, `input.py`, `output.py` (plus `input_validator.py` on the request task if\n  it needs validation).\n. Register both task names in `PREDEFINED_PROCESS_STEPS` (`task/process_factory.py`).\n. Add dispatch branches in `TaskFactory.build` injecting the clients the tasks need.\n. If a new vendor client is required, add it under `components/` and wire it into the\n  `TaskFactory` construction in `swf_app.py` (and any Web API handler that builds a factory).\n. If the process maps to a tool, extend `ToolType` and `ToolType.get_by_process_name`\n  (`components/aws/dynamodb/tool/tool_type.py`), and `ToolTable.SUPPORTED_TOOLS` if the row\n  should be auto-created for every supplier.\n. If underwriters may override its verdict, add the process to `VALIDATION_FIELDS` in\n  `interface/web_api/endpoint_handling/update_final_status.py`.\n. Add unit tests under `development/unit_tests/task_/…` and update these docs\n  (xref:processes/index.adoc[], xref:data/dynamodb.adoc[],\n  xref:events/eventbridge-events.adoc[]).\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/index.adoc","title":"Process catalog","description":"The underwriting processes, who may start them, and what each one writes.","url":"https://docx.payability.com/automatic-uw-tool/processes/index.html","navOrder":4,"source":"= Process catalog\n:navtitle: Process catalog\n:description: The underwriting processes, who may start them, and what each one writes.\n\nFour processes, five implementations (identity documents have both an Au10tix and an Entrust\nflavour). Framework details are in xref:domain-model.adoc[].\n\ninclude::partial$entrust-coexistence.adoc[]\n\n== Catalog\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Process | Vendor | Page\n\n| `validate_identity_document`\n| Au10tix\n| xref:processes/validate-identity-document-au10tix.adoc[]\n\n| `validate_identity_document`\n| Entrust (Onfido)\n| xref:processes/validate-identity-document-entrust.adoc[]\n\n| `validate_personal_information`\n| Thomson Reuters CLEAR (EIDV)\n| xref:processes/validate-personal-information.adoc[]\n\n| `download_personal_background_report`\n| Thomson Reuters CLEAR\n| xref:processes/download-personal-background-report.adoc[]\n\n| `download_business_background_report`\n| Thomson Reuters CLEAR\n| xref:processes/download-business-background-report.adoc[]\n|===\n\n== Behaviour matrix\n\n[cols=\"2,1,1,1,1,1\",options=\"header\"]\n|===\n| Behaviour | ID doc (Au10tix) | ID verification (Entrust) | Personal info (KYC) | Personal background | Business background\n\n| Allowed sources\n| internal + supplier dashboard\n| internal + supplier dashboard + onboarding\n| internal + supplier dashboard\n| internal only\n| internal only\n\n| Has an input validator\n| no\n| n/a (pydantic body)\n| yes\n| yes\n| yes\n\n| Writes `FinalStatusTable`\n| yes\n| yes (terminal verdicts only)\n| yes\n| yes\n| *no*\n\n| Writes `ToolTable`\n| yes (`AU10TIX`)\n| yes (`ENTRUST`)\n| yes (`THOMSON_REUTERS_CLEAR_ID`)\n| yes, tiered\n| *no*\n\n| Emits EventBridge\n| `ProcessFinished`\n| `ProcessFinished` (terminal only)\n| TR match / mismatch / not-working + `ProcessFinished`\n| `ProcessFinished`\n| *none*\n\n| Implements `purge()`\n| no\n| no\n| yes\n| yes\n| yes\n\n| Handle-step input\n| full fields in the SWF payload\n| `{supplier_key, person_id, applicant_id, workflow_run_id}`\n| `\\{correlation_id}` only\n| `\\{correlation_id}` only\n| `\\{correlation_id}` only\n\n| Syncs HubSpot\n| no\n| yes (best effort)\n| no\n| no\n| no\n|===\n\n== Verdict vocabulary\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Process | `FinalStatusTable.data` shape\n\n| `validate_identity_document`\n| `DocumentAuthenticity`, `IDNameMatch`, `IsDocumentExpired`, `OverallQuality` — each `VERIFIED` or `NOT_VERIFIED`\n\n| `validate_personal_information`\n| one key per matched PII field — `MATCH` or `NO_MATCH`\n\n| `download_personal_background_report`\n| `{access: {<Section>: PASS\\|FAIL\\|ERROR}, advance: {…}}`\n\n| `download_business_background_report`\n| not written\n|===\n\nUnderwriters can override these through\nxref:api/tasks.adoc#update-final-status[`PUT /tasks/\\{correlation_id}/final_statuses`], which\nalso accepts `PENDING` and recomputes the tool status from the override.\n\nTool statuses (`ToolStatus`): `NOT_RUN`, `PENDING`, `ERROR`, `NOT_VERIFIED`, `VERIFIED`,\n`VERIFIED_ACCESS`, `VERIFIED_ADVANCE`, `VERIFIED_ACCESS_AND_ADVANCE`. The tiered values apply\nonly to the personal background report.\n\n== Rate limits and dedup\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Rule | Where\n\n| Identical non-failed in-flight execution returns the existing `correlation_id`\n| `POST /tasks/execute` (input equality on `task_input`)\n\n| 3 executions per supplier + process for `SUPPLIER_DASHBOARD`\n| `POST /tasks/execute` → `429`\n\n| `force_new_execution` bypasses dedup (internal only, else `403`)\n| `POST /tasks/execute`\n\n| A pending Entrust session with an unexpired hosted link is reused\n| `POST /entrust/sessions`\n\n| 3 Entrust sessions per person for `SUPPLIER_DASHBOARD` and `ONBOARDING`; `INTERNAL` unlimited\n| `POST /entrust/sessions` → `429`\n\n| Concurrent webhook redeliveries dedup on a deterministic SWF `workflowId`\n| `POST /entrust/webhook`\n\n| An already-`processed` Entrust task short-circuits without reprocessing\n| `entrust/handle_identity_document_validation_response`\n|===\n\n== Adding a process\n\nSee xref:domain-model.adoc#adding-a-process[the checklist in the domain model page].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/validate-identity-document-au10tix.adoc","title":"validate_identity_document (Au10tix)","description":"Verify an uploaded government ID with Au10tix and match the name against the supplier owner.","url":"https://docx.payability.com/automatic-uw-tool/processes/validate-identity-document-au10tix.html","navOrder":5,"source":"= validate_identity_document (Au10tix)\n:navtitle: Identity document (Au10tix)\n:description: Verify an uploaded government ID with Au10tix and match the name against the supplier owner.\n\n*Business purpose:* verify that a government ID uploaded to Payability is authentic, unexpired,\nof acceptable quality, and that its name matches the supplier owner on record.\n\ninclude::partial$entrust-coexistence.adoc[]\n\n== Step 1 — `au10tix/request_identity_document_validation`\n\n`InitialTask`, runs in the Web API.\n\n[cols=\"1,3\"]\n|===\n| Input | `supplier_key`, `person_id`, `document_id`\n| Allowed sources | `INTERNAL`, `SUPPLIER_DASHBOARD`\n| Input validator | none\n| Output | `\\{correlation_id}`\n|===\n\nSide effects, in order:\n\n. `TaskExecutionTable.save` — `status=pending`, `process_name=validate_identity_document`,\n  `input` = the three fields, plus `source` and `requested_by`.\n. `ToolTable.update` — `AU10TIX` → `PENDING` with this `correlation_id`.\n. `B2BTaskManager.start_swf` — workflow id\n  `Automatic UW Tool - \\{supplier_key} - Validate identity document.`, carrying the handle task\n  name and the full input (this is the only process that passes real fields rather than just\n  the correlation id).\n\n[source,python]\n----\ninput_body={\n    'platform': 'automatic-uw',\n    'task_name': 'au10tix/handle_identity_document_validation_response',\n    'task_correlation_id': self.correlation_id,\n    'task_input': {\n        'document_id': input_.document_id,\n        'supplier_key': input_.supplier_key,\n        'person_id': input_.person_id\n    }\n}\n----\n\nBecause the workflow id is deterministic per supplier, a second concurrent request for the same\nsupplier surfaces `CannotRunWorkflow` (\"Task already running\") as a `400`.\n\n== Step 2 — `au10tix/handle_identity_document_validation_response`\n\n`GenericTask`, runs on the worker.\n\n=== Flow\n\n. `PYBDocuments.get_document(document_id)` — download the uploaded image bytes\n  (`DocumentNotFound` on a 404).\n. `POST \\{AU10TIX_API_URL}/IdentityDocuments/BeginProcessingRequest` — multipart upload of\n  `requestData` (the `process_identity_document.json` template) and `documentImage_Page0`;\n  returns a `RequestId`.\n. Poll `POST /IdentityDocuments/CheckReadinessOfResults` with `{\"idList\": [request_id]}` until\n  the first element is `true` — 10 attempts, 5 s apart.\n. `GET /IdentityDocuments/Results/\\{request_id}` — accepted only when `CompletionStatus == 'Ok'`;\n  anything else raises and is retried (10 attempts, 5 s, also forcing a retry on\n  `AttributeError`).\n. Archive the raw result JSON to S3.\n. Compare the document name against the supplier owner from the Payability internal API.\n\n=== Verdict rules\n\nDerived in `__prepare_final_status_data` from the Au10tix payload:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Final-status field | `VERIFIED` when\n\n| `DocumentAuthenticity` | `validation_result['DocumentAuthenticity'] == 'Authentic'`\n| `IsDocumentExpired` | `ProcessingResult['IsDocumentExpired'] is False`\n| `OverallQuality` | `ProcessingResult['OverallQuality'] == 'Acceptable'`\n| `IDNameMatch` | the document name fuzzy-matches the owner name\n|===\n\nName matching: `DocumentData2.FirstName/LastName` are used when both are present, otherwise\n`FullName` is split on spaces (first token, last token). The owner comes from\n`PayabilityInternalAPI.get_supplier_info(detailed=True)` matched on `loginKey == person_id`;\ntheir `firstName` is stripped of middle names. Comparison is\n`utils.string_utils.is_full_name_match` — accent-folded `thefuzz.token_set_ratio` at 100.\nA `400`/`404` from the internal API yields `NOT_VERIFIED` rather than a task failure; other\nHTTP errors propagate.\n\nThe tool becomes `VERIFIED` only when no field is `NOT_VERIFIED`.\n\n=== Side effects\n\n* S3: `au10tix/identity_document_validation/supplier_key=<sk>/person_id=<pid>/<timestamp>.json`.\n* `TaskExecutionTable` → `processed`, `output = \\{raw_result_s3_path}`,\n  `additional_data.full_result_s3_path`.\n* `FinalStatusTable.save` — the four fields above.\n* `ToolTable.update` — `AU10TIX` → `VERIFIED` / `NOT_VERIFIED`.\n* EventBridge: `ProcessFinishedEvent` with `DetailType = VALIDATE_IDENTITY_DOCUMENT`.\n* Output: `\\{raw_result_s3_path}`.\n\n=== Failure path\n\n`task_failure_handling` marks the task `failed` with `{'error': str(e)}` and sets the `AU10TIX`\ntool to `ERROR` (reading the supplier and person back off the persisted row). This task does\n*not* implement `purge()`, so `DELETE /tasks/result` deletes nothing for it.\n\n== Source files\n\n* `task/au10tix/request_identity_document_validation/`\n* `task/au10tix/handle_identity_document_validation_response/`\n* `components/au10tix/client.py`, `components/au10tix/request_template/`\n* Tests: `development/unit_tests/task_/au10tix/**`\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/validate-identity-document-entrust.adoc","title":"validate_identity_document (Entrust / Onfido)","description":"The hosted Entrust identity-verification flow, its webhook, and the asynchronous aftermath task.","url":"https://docx.payability.com/automatic-uw-tool/processes/validate-identity-document-entrust.html","navOrder":6,"source":"= validate_identity_document (Entrust / Onfido)\n:navtitle: Identity verification (Entrust)\n:description: The hosted Entrust identity-verification flow, its webhook, and the asynchronous aftermath task.\n\n*Business purpose:* let a supplier owner verify their identity themselves through Entrust\nIdentity Verification (Onfido) — document capture plus a liveness check — then persist the\ncaptured artifacts and the verdict back into Payability.\n\ninclude::partial$entrust-coexistence.adoc[]\n\n== Shape of the flow\n\nUnlike the other processes this is *not* a two-step `ProcessFactory` chain:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Stage | Implementation\n\n| \"Request\"\n| `POST /entrust/sessions` — xref:api/entrust.adoc#create-session[endpoint handler], not a task. Mints the Onfido applicant + workflow run and persists the pending task and tool rows.\n\n| Trigger\n| `POST /entrust/webhook` — a thin, signature-verified receiver that only schedules the async task.\n\n| \"Handle\"\n| `entrust/handle_identity_document_validation_response` — the SWF activity that does all the work. Registered in `TaskFactory`, absent from `PREDEFINED_PROCESS_STEPS`.\n|===\n\nRerunning is possible: `POST /tasks/execute` with\n`task_name = \"entrust/handle_identity_document_validation_response\"` reprocesses the supplier's\nlatest Entrust run (internal callers only) — see xref:api/tasks.adoc#entrust-reprocess[].\n\n== Session creation\n\nSummarised here; full request/response contract in xref:api/entrust.adoc#create-session[].\n\n. Authorize by source — `INTERNAL` needs `DEV`/`FINANCE`; `SUPPLIER_DASHBOARD` and `ONBOARDING`\n  are self-service.\n. Resolve identity — supplier-dashboard callers are pinned to their JWT identity; internal and\n  onboarding callers must pass `supplier_key` + `person_id`.\n. Reuse a pending session whose `link_expires_at` is still in the future, otherwise enforce the\n  3-sessions-per-person cap for self-service sources.\n. Fetch the owner's first/last name from the Payability internal API (Onfido requires them).\n. `EntrustClient.create_applicant` then `create_workflow_run`, tagging the run with\n  `custom_data = \\{supplier_key}`.\n. Persist: `TaskExecutionTable.save(task_id=workflow_run_id, …)` and\n  `ToolTable.update(ENTRUST, PENDING)`.\n. Best-effort external syncs → HubSpot `entrust_verification_status = in_progress`, plus a\n  supplier event with the same status.\n\n== Webhook receiver\n\n`interface/web_api/endpoint_handling/handle_entrust_webhook.py`:\n\n* *Verify* — `X-SHA2-Signature` must equal `HMAC-SHA256(raw_body, ENTRUST_WEBHOOK_TOKEN)` in\n  hex, compared with `hmac.compare_digest`. Missing or mismatched → `401`. A body that is not\n  valid JSON → `400`.\n* *Filter* — only `payload.resource_type == \"workflow_run\"` and\n  `payload.action == \"workflow_run.completed\"` are processed; anything else is acknowledged with\n  `200 {\"status\": \"ignored\"}` so Onfido stops retrying.\n* *Correlate* — `payload.object.id` is the `workflow_run_id`, i.e. the `TaskExecutionTable`\n  `task_id`. The row supplies `supplier_key`, `person_id` and `applicant_id`. No row → `200\n  {\"status\": \"ignored\"}` without scheduling.\n* *Enqueue* — `B2BTaskManager.start_swf` with a deterministic workflow id\n  `Automatic UW Tool - Entrust - \\{workflow_run_id}`, so concurrent redeliveries dedup inside\n  SWF; a `CannotRunWorkflow` \"already started\" is treated as already-scheduled and still\n  answered `200 {\"status\": \"accepted\"}`.\n* Any unexpected error → `500`, which makes Onfido retry the delivery.\n\n[source,json]\n----\ninclude::example$entrust-webhook-payload.json[]\n----\n\n== The aftermath task\n\n`task/entrust/handle_identity_document_validation_response/`. Input:\n`{supplier_key, person_id, workflow_run_id, applicant_id?}`. `correlation_id ==\nworkflow_run_id`.\n\n=== 1. Idempotency guard\n\nIf the persisted row is already `processed`, a redelivered webhook produced a duplicate run:\nthe task logs and returns the existing `output` (`raw_result_s3_path`, `status`,\n`document_actions`) without touching anything. A missing row is tolerated — processing\ncontinues and the terminal `update` upserts it.\n\n=== 2. Re-read and archive the run\n\n`EntrustClient.get_workflow_run` is the source of truth for `status` (the webhook body carries\nonly a minimal object). `get_workflow_run_tasks` then hydrates every step with its `input` and\n`output`, and the merged document (`{...run, tasks: [...]}`) is archived to:\n\n[source]\n----\nentrust/identity_document_validation/supplier_key=<sk>/person_id=<pid>/<timestamp>.json\n----\n\nStep hydration is *best effort*: it fans out into one request per step, and if it fails the run\nsummary is archived without `tasks` rather than blocking the verdict.\n\n=== 3. Document handling (required)\n\nGuarded by `additional_data.documents_uploaded`; skipped entirely when the session recorded no\n`applicant_id`. A failure anywhere in this step fails the task so SWF retries — the raised\nerror carries the upstream response body so the rejection reason is visible.\n\n. *Archive stale captures* — `list_supplier_documents(IDENTIFICATION_DOCUMENT)`, then a single\n  `update_supplier_document_statuses(..., 'archived')` over every document whose\n  `compliance.status` is not already `archived` (both `pending` and `approved`). The fresh\n  capture must fully supersede prior attempts.\n. *Upload the identity documents + selfie* — `get_applicant_files(applicant_id)` downloads the\n  Onfido documents and live photos, and `upload_supplier_documents` posts them as one\n  multipart request.\n. *Upload the liveness media* — `get_applicant_liveness_files(applicant_id)` (live videos and\n  motion captures) in a *second* request, after the documents.\n\nBoth uploads use `document_type = IDENTIFICATION_DOCUMENT` and the KYC identity\n`compliance_key`, and carry positionally-aligned per-file metadata (one entry per `files` part,\nempty string where absent):\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Value\n\n| `side` | `front` / `back` — identity documents only.\n| `document_types` | The Onfido document type (e.g. `driving_licence`); `video_selfie` for a liveness video; empty otherwise.\n| `issuing_country` | The ISO issuing country — identity documents only.\n| `expiration_date` | The run's `date_of_expiry`, taken from the document-check step's `output.properties`; applied to documents and liveness media, empty for the selfie.\n|===\n\nThe ids returned by the uploads land on `additional_data.uploaded_document_ids`, and a summary\non `additional_data.document_actions`:\n\n[source,json]\n----\ninclude::example$entrust-document-actions.json[]\n----\n\n=== 4. Verdict\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Onfido `status` | Result\n\n| `review`\n| Manual review pending. The `ENTRUST` tool stays `PENDING`, the task row stays `pending` (only the guard flags and `document_actions` are persisted), no final status is written and no `ProcessFinishedEvent` is emitted. External syncs → `in_review`.\n\n| `approved`\n| Verdict `VERIFIED`. The freshly uploaded documents are promoted with `update_supplier_document_statuses(..., 'approved')` (guarded by `additional_data.documents_approved`); the internal API cascades the \"Upload ID\" compliance status once all identity documents are approved. External syncs → `verified`.\n\n| anything else (`declined`, `abandoned`, `error`, …)\n| Verdict `NOT_VERIFIED`; the uploaded documents stay pending for manual handling. External syncs → `denied`.\n|===\n\nOnfido gives one overall verdict, so all four identity-document final-status fields\n(`DocumentAuthenticity`, `IDNameMatch`, `IsDocumentExpired`, `OverallQuality`) mirror it —\nkeeping the existing override schema intact.\n\nOn a terminal verdict the task:\n\n* upserts `FinalStatusTable` (idempotent: `save` if absent, `update` if present),\n* sets `ToolTable` `ENTRUST` → `VERIFIED` / `NOT_VERIFIED`,\n* marks the task `processed` with an enriched `output` (S3 pointer plus the salient run fields:\n  `workflow_run_id`, `applicant_id`, `workflow_id`, `workflow_version_id`, `status`,\n  `dashboard_url`, `output`, `reasons`, `error`, `created_at`, `updated_at`,\n  `document_actions`; `None` values are dropped) and\n  `additional_data.full_result_s3_path`,\n* emits `ProcessFinishedEvent`,\n* syncs HubSpot and emits the supplier event.\n\nThe SWF activity result itself is compact so the SWF console is useful on its own:\n\n[source,json]\n----\ninclude::example$entrust-task-output.json[]\n----\n\n=== 5. Failure path\n\n`_update_task_failure_in_task_execution_table` marks the task `failed` and the `ENTRUST` tool\n`ERROR`, preferring the identity captured at the start of `execute()` so it still works when\nthe persisted row is missing. Neither HubSpot nor EventLogger can cause a failure — both clients\nswallow their own errors.\n\n== External status syncs\n\nTwo downstream sinks receive the same lifecycle status. Both are best effort — a failure in either\nis logged and reported to Sentry but never fails the request or the task:\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Moment | Status value | Sinks\n\n| `POST /entrust/sessions` succeeded\n| `in_progress`\n| HubSpot contact property + supplier event\n\n| Run `approved`\n| `verified`\n| both; the event also carries `verdict = VERIFIED`\n\n| Run `review`\n| `in_review`\n| both; no verdict yet\n\n| Any other terminal status\n| `denied`\n| both; `verdict = NOT_VERIFIED`\n|===\n\nIn the aftermath task one `__sync_external_status` call drives both.\nSee xref:components/hubspot.adoc[] and xref:components/event-logger.adoc[].\n\n== Source files\n\n* `interface/web_api/endpoint_handling/create_entrust_session.py`\n* `interface/web_api/endpoint_handling/handle_entrust_webhook.py`\n* `interface/web_api/router/entrust_router.py`\n* `task/entrust/handle_identity_document_validation_response/`\n* `components/entrust/client.py`, `components/hubspot/client.py`,\n  `components/event_logger/client.py`\n* Tests: `development/unit_tests/task_/entrust/**`,\n  `development/unit_tests/components_/entrust/test_client.py`,\n  `development/unit_tests/components_/test_hubspot_client.py`,\n  `development/unit_tests/components_/test_supplier_event_logger.py`,\n  `development/unit_tests/interface_/web_api/endpoint_handling/test_handle_entrust_webhook.py`,\n  `…/test_create_entrust_session_hubspot.py`,\n  `…/test_execute_task_entrust_reprocess.py`\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/validate-personal-information.adoc","title":"validate_personal_information (KYC)","description":"Confirm a supplier owner's PII against Thomson Reuters CLEAR EIDV records, field by field.","url":"https://docx.payability.com/automatic-uw-tool/processes/validate-personal-information.html","navOrder":7,"source":"= validate_personal_information (KYC)\n:navtitle: Personal information (KYC)\n:description: Confirm a supplier owner's PII against Thomson Reuters CLEAR EIDV records, field by field.\n\n*Business purpose:* the KYC check. Confirm that the PII a supplier owner submitted (name, date\nof birth, SSN, address) matches Thomson Reuters records, and tell the rest of the platform\nwhich individual fields did not match.\n\n== Step 1 — `thomson_reuters/request_personal_information_validation`\n\n`InitialTask`, runs in the Web API.\n\n[cols=\"1,3\"]\n|===\n| Input | `supplier_key`, `person_id`, `first_name`, `last_name`, `birth_day`, `birth_month`, `birth_year`, `ssn`, `street`, `city`, `state`, `zip_code`\n| Allowed sources | `INTERNAL`, `SUPPLIER_DASHBOARD`\n| Input validator | yes\n| Output | `\\{correlation_id}`\n|===\n\nSide effects: save the row (`pending`), set `THOMSON_REUTERS_CLEAR_ID` → `PENDING`, validate,\nthen start SWF with `task_input = \\{correlation_id}` only — the handle step reloads the PII from\nDynamoDB rather than putting it on the workflow payload. Workflow id:\n`Automatic UW Tool - \\{supplier_key} - Validate personal informations.`\n\n=== Input validation\n\n`input_validator.py` raises `ValidationError` (a `TaskExecutionError`, so the API answers `400`\nwith `{\"incorrect_fields\": {...}}`):\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Rule\n\n| `birth_day` | `^(0[1-9]\\|1[0-9]\\|2[0-9]\\|3[0-1])$` — two digits, `DD`.\n| `birth_month` | `^(0[1-9]\\|1[012])$` — two digits, `MM`.\n| `state` | Exactly two uppercase letters.\n| `street` | Must start with `<alphanumeric> <alphanumeric>` (house number + street name, PO box, rural route or highway contract route) and must not contain `’` or `\"`.\n| `first_name`, `last_name` | `A-Z a-z 0-9`, space and ``~`@#$%^()_-+={}\\|[]:;\"'<>?,./`` only.\n| `ssn` | `000-00-0000`.\n| `supplier_key`, `person_id` | Required.\n|===\n\nNote that the task row and the `PENDING` tool status are written *before* validation runs, so a\nrejected input still leaves a `failed` task and an `ERROR` tool behind (the failure handler runs\non the `ValidationError`).\n\n== Step 2 — `thomson_reuters/handle_personal_information_validation_response`\n\n`GenericTask` + `PurgableS3ObjectProducer`, runs on the worker. Input: `\\{correlation_id}`.\n\n=== Flow\n\n. Reload the row: `TaskExecutionTable.get(correlation_id)` → `input` (the PII).\n. Fill `clear_id_confirm_person_search.xml` with the permissible-purpose block and the criteria,\n  and `POST \\{THOMSON_REUTERS_API_URL}/v2/eidvperson/searchResults`.\n. Read `GroupCount` from the `ns*:EIDVPersonResults` node (the namespace prefix is discovered at\n  runtime by `find_key_in_tr_response`). `0` or missing → `ResultNotFound`.\n. Read `Uri`, then `GET` it to fetch the actual search result. A missing `Uri` →\n  `MalformedResponse`.\n. Archive the result to S3.\n. Extract the per-field summary from\n  `…EIDVPersonSearchResults.EIDVPersonSearchResult.PersonEntities.PersonEntity.Summary`.\n\nAny `HTTPError` on either call emits `ThomsonReutersNotWorkingEvent` (so the platform can let\nthe customer proceed without the check) and then fails the task.\n\n=== Match scoring\n\nEach summary field carries a `@matchtype`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `@matchtype` | Treated as\n\n| `MATCH` | matched → `MATCH` in the final status\n| `PARTIAL MATCH` | *not* matched → `NO_MATCH`\n| `NO MATCH` | not matched → `NO_MATCH`\n|===\n\n* All fields matched → `ThomsonReutersDataMatchEvent`.\n* One or more mismatched → `ThomsonReutersDataNotMatchEvent` carrying the mismatched field names\n  in `data_points`.\n* The tool is `VERIFIED` only when every field matched.\n* `@matchscore` is stripped from the output before it is persisted.\n\n=== Side effects\n\n* S3: `thomson_reuters/kyc/supplier_key=<sk>/person_id=<pid>/<timestamp>.person_search.json`.\n* `TaskExecutionTable` → `processed`, `output = \\{match_result}`,\n  `additional_data.full_result_s3_path`.\n* `FinalStatusTable.save` — one key per field with a `@matchtype`, valued `MATCH` / `NO_MATCH`.\n* `ToolTable.update` — `THOMSON_REUTERS_CLEAR_ID` → `VERIFIED` / `NOT_VERIFIED`.\n* EventBridge: the match/mismatch/not-working event above plus `ProcessFinishedEvent`.\n* Output: `\\{match_result}` — field → `{\"@matchtype\": \"MATCH\" | \"PARTIAL MATCH\" | \"NO MATCH\"}`.\n\n=== Data deletion\n\n`purge(supplier_key, person_id, s3_object_purger)` deletes the archived S3 object of every\n`processed` execution for that person and marks those rows `purged`. Driven by\nxref:api/tasks.adoc#delete-task-results[`DELETE /tasks/result`].\n\n=== Result masking\n\n`GET /tasks/result?detailed=true` masks the CLEAR ID payload for callers who lack\n`DEV`/`FINANCE`/`REST_UW_ACCESS`: everything in `NormalizedCriteria` and in each `SearchRecord`\nexcept `FirstName`/`LastName` is replaced with `********`. See\nxref:api/tasks.adoc#get-task-result[].\n\n== Overridable fields\n\n`PUT /tasks/\\{correlation_id}/final_statuses` accepts `City`, `FirstName`, `LastName`, `SSN`,\n`State`, `StreetName`, `StreetNumber`, `ZipCode`, `BirthDate`, `MiddleName`, `PhoneNumber` with\nvalues `MATCH`, `NO_MATCH` or `PENDING`, and requires every key that the task's `match_result`\nreported.\n\n== Source files\n\n* `task/thomson_reuters/request_personal_information_validation/`\n* `task/thomson_reuters/handle_personal_information_validation_response/`\n* `components/thomson_reuters/client.py`,\n  `components/thomson_reuters/request_template/clear_id_confirm_person_search.xml`\n* Tests: `development/unit_tests/task_/thomson_reuters/**`\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/download-personal-background-report.adoc","title":"download_personal_background_report","description":"Pull a Thomson Reuters CLEAR personal background report and score it into access and advance verdicts.","url":"https://docx.payability.com/automatic-uw-tool/processes/download-personal-background-report.html","navOrder":8,"source":"= download_personal_background_report\n:navtitle: Personal background report\n:description: Pull a Thomson Reuters CLEAR personal background report and score it into access and advance verdicts.\n\n*Business purpose:* pull a full personal background report for a supplier owner and derive an\nunderwriting verdict split into two tiers — *access* (can they use the platform) and *advance*\n(can they receive advances).\n\n== Step 1 — `thomson_reuters/request_personal_background_report`\n\n`InitialTask`, runs in the Web API.\n\n[cols=\"1,3\"]\n|===\n| Input | `supplier_key`, `person_id`, `first_name`, `last_name`, `ssn`, optional `test`, `test_timeout`\n| Allowed sources | `INTERNAL` only\n| Input validator | yes (name + SSN)\n| Output | `\\{correlation_id}`\n|===\n\nSide effects: save the row (`pending`), set `THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT` →\n`PENDING`, validate, start SWF with `task_input = \\{correlation_id}`. Workflow id:\n`Automatic UW Tool - \\{supplier_key} - Download personal background report.`\n\n== Step 2 — `thomson_reuters/handle_personal_background_report_response`\n\n`GenericTask` + `PurgableS3ObjectProducer`. Input: `\\{correlation_id}`.\n\n=== Flow\n\n. Reload the row and build the search payload from `background_person_search.xml`\n  (`first_name`, `last_name`, `ssn`).\n. `POST \\{THOMSON_REUTERS_API_URL}/v3/person/searchResults`; read `GroupCount` from\n  `ns*:PersonResults` (`0` or missing → `ResultNotFound`) and `Uri` (missing →\n  `MalformedResponse`), then `GET` the search result.\n. Resolve the person:\n+\n--\n* *One group* — take `ResultGroup.GroupId` from `ns*:PersonResultsPageV3`.\n* *Several groups* — iterate `ResultGroup` and pick the record whose `FirstName`, `LastName`\n  (case-insensitive) and `SSN` all equal the task input; no match → `ResultNotFound`. A missing\n  node anywhere in that scan → `MalformedResponse`.\n--\n. `POST /v2/personReport/reportResults` with `background_person_report.xml` for that `GroupId`,\n  then poll the returned `Uri` until the report's `Status.StatusCode` is no longer `204`\n  (`Retry(total=20)` on the worker, 10 s apart).\n. Archive the report to S3 and score it.\n\n=== Section scoring\n\nSeven sections must be present, otherwise `MissingResponseSection` is raised:\n\n`ArrestSection`, `BankruptcySection`, `CorporateSection`, `LawsuitSection`,\n`LienJudgmentSection`, `UCCSection`, `CriminalSection`.\n\nEach section is scored from `SectionStatus` and `SectionRecordCount`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Result | Condition\n\n| `ERROR` | `SectionStatus` is `Error`, `ERROR` or `INCOMPLETE`.\n| `PASS` | `SectionStatus == COMPLETE` and `SectionRecordCount == 0` — except `CorporateSection`, which passes with `SectionRecordCount <= 1` (owning one company is normal).\n| `FAIL` | anything else, including a missing key.\n|===\n\nThe same per-section result is written to *both* the `access` and the `advance` tier initially.\n\n=== Carrying forward manual overrides\n\n`__prepare_final_status_data` returns two structures: the *default* statuses computed from this\nrun, and the *effective* statuses saved as the final status.\n\nIf a previous `processed` execution exists for the same supplier + person, its archived report\nis re-scored and compared section by section with this run:\n\n* If this run's default result differs from the previous run's default → use the new default\n  (the underlying data changed).\n* If they are identical → keep the previous run's *final* status, preserving any manual\n  underwriter override.\n* `ERROR` is normalised to `FAIL` for the purposes of that comparison.\n* No previous task, no archived report, or no stored final status → defaults are used for both.\n\nThe computed defaults are also persisted on `additional_data.default_result_statuses` and\nsurfaced by the detailed `GET /tasks/result` as `default_result_status`, so an admin UI can show\n\"vendor says X, we decided Y\".\n\n=== Tool status tiers\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `ToolStatus` | When\n\n| `VERIFIED_ACCESS_AND_ADVANCE` | Neither tier contains `ERROR`, `FAIL` or `PENDING`.\n| `VERIFIED_ACCESS` | Only the `access` tier is clean.\n| `NOT_VERIFIED` | The `access` tier contains `ERROR`, `FAIL` or `PENDING`.\n|===\n\n(`VERIFIED_ADVANCE` exists in the enum but the \"advance clean, access dirty\" combination\nresolves to `NOT_VERIFIED`.) The same calculation is reused by\nxref:api/tasks.adoc#update-final-status[`PUT /tasks/\\{correlation_id}/final_statuses`].\n\n=== Test mode\n\n`test=true` in the request input makes the handle step skip Thomson Reuters entirely and score\n`./examples/thomson_reuters_personal_background_check_response.json`. Adding\n`test_timeout=true` sleeps 360 s first, to exercise SWF activity timeouts. Both are intended for\nnon-production environments.\n\n=== Side effects\n\n* S3: `thomson_reuters/background_reports/supplier_key=<sk>/person_id=<pid>/person/<timestamp>.json`.\n* `TaskExecutionTable` → `processed`, `output = \\{raw_result_s3_path}`,\n  `additional_data = {full_result_s3_path, default_result_statuses}`.\n* `FinalStatusTable.save` — `{access: {...}, advance: {...}}`.\n* `ToolTable.update` — tiered status as above.\n* EventBridge: `ProcessFinishedEvent` with `DetailType = DOWNLOAD_PERSONAL_BACKGROUND_REPORT`.\n* Implements `purge()`.\n\n== Overridable fields\n\n`PUT /tasks/\\{correlation_id}/final_statuses` accepts the keys `access` and `advance`, each a map\nof the seven section names to `PASS`, `FAIL`, `ERROR` or `PENDING`.\n\n== Source files\n\n* `task/thomson_reuters/request_personal_background_report/`\n* `task/thomson_reuters/handle_personal_background_report_response/`\n* `components/thomson_reuters/request_template/background_person_search.xml`,\n  `background_person_report.xml`\n* Tests: `development/unit_tests/task_/thomson_reuters/**`\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"processes/download-business-background-report.adoc","title":"download_business_background_report","description":"Pull and archive a Thomson Reuters CLEAR business-entity background report.","url":"https://docx.payability.com/automatic-uw-tool/processes/download-business-background-report.html","navOrder":9,"source":"= download_business_background_report\n:navtitle: Business background report\n:description: Pull and archive a Thomson Reuters CLEAR business-entity background report.\n\n*Business purpose:* pull a background report for the supplier's business entity and archive it\nfor underwriters to read. This is the simplest process — it stores raw results and scores\nnothing.\n\n== Step 1 — `thomson_reuters/request_business_background_report`\n\n`InitialTask`, runs in the Web API.\n\n[cols=\"1,3\"]\n|===\n| Input | `supplier_key`, `person_id`, `business_name`, `state`\n| Allowed sources | `INTERNAL` only\n| Input validator | yes (business name + required state)\n| Output | `\\{correlation_id}`\n|===\n\nSide effects: save the row (`pending`) and start SWF with `task_input = \\{correlation_id}`.\nWorkflow id: `Automatic UW Tool - \\{supplier_key} - Download business background report.`\n\n[NOTE]\n====\nThis is the only request task that does *not* touch `ToolTable` — there is no business-report\ntool, so nothing is set to `PENDING` and nothing turns `ERROR` on failure.\n====\n\n== Step 2 — `thomson_reuters/handle_business_background_report_response`\n\n`GenericTask` + `PurgableS3ObjectProducer`. Input: `\\{correlation_id}`.\n\n=== Flow\n\n. Reload the row and build the search payload from `background_business_search.xml`\n  (`business_name`, `state`).\n. `POST \\{THOMSON_REUTERS_API_URL}/v2/business/searchResults`; read `GroupCount` from\n  `ns2:BusinessResults` (`0` or missing → `ResultNotFound`) and `Uri` (missing →\n  `MalformedResponse`), then `GET` the search result.\n. Read `ns3:BusinessResultsPage.ResultGroup.GroupId` (missing → `MalformedResponse`).\n. `POST /v2/businessReport/reportResults` with `background_business_report.xml`, then poll the\n  returned `Uri` until `Status.StatusCode` is no longer `204`. The details node is read as\n  `ns10:BusinessReportDetails` with a fallback to `ns11:BusinessReportDetails`.\n. Archive the report to S3.\n\n[NOTE]\n====\nUnlike the other Thomson Reuters tasks this one addresses namespace-prefixed nodes literally\n(`ns2:`, `ns3:`, `ns10:`/`ns11:`) instead of discovering the prefix with\n`find_key_in_tr_response`. A CLEAR response whose prefixes shift will raise a `KeyError` here.\n====\n\n=== Side effects\n\n* S3: `thomson_reuters/background_reports/supplier_key=<sk>/person_id=<pid>/business/<timestamp>.json`.\n* `TaskExecutionTable` → `processed`, `output = \\{raw_result_s3_path}`,\n  `additional_data.full_result_s3_path`.\n* *No* `FinalStatusTable`, *no* `ToolTable`, *no* EventBridge event.\n* Implements `purge()`.\n* On failure only the task row is marked `failed` (no tool exists to flag). This task is also\n  built without a logger, so its failures are visible through SWF and Sentry rather than\n  application logs.\n\n== Source files\n\n* `task/thomson_reuters/request_business_background_report/`\n* `task/thomson_reuters/handle_business_background_report_response/`\n* `components/thomson_reuters/request_template/background_business_search.xml`,\n  `background_business_report.xml`\n* Tests: `development/unit_tests/task_/thomson_reuters/**`\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"api/index.adoc","title":"API overview","description":"The FastAPI application, its routers, authentication, authorization and the full route table.","url":"https://docx.payability.com/automatic-uw-tool/api/index.html","navOrder":10,"source":"= API overview\n:navtitle: API overview\n:description: The FastAPI application, its routers, authentication, authorization and the full route table.\n\nThe Web API (`web_api_app.py`) is the only HTTP surface. It is served by the Web API container\nexclusively — the worker exposes nothing.\n\n== Application\n\n[source,python]\n----\napp = FastAPI(\n    title='automatic-uw-tool',\n    description='Automatic Underwriting Tool',\n    docs_url=settings.FAST_API_DOCS_URL,\n    redoc_url=settings.FAST_API_REDOC_URL,\n    **extra_args          # root_path, non-release environments only\n)\n----\n\n* *CORS* — `allow_origin_regex = FAST_API_CORS_ALLOW_ORIGIN` (`.*.payability.com` in\n  `{prod-env}`, `.*.dev.payability.com` in dev), credentials allowed, all methods and headers.\n* *Sentry* — `SentryAsgiMiddleware` wraps the app; `sentry_sdk.init` uses `ENV_NAME` as the\n  environment.\n* *Routers* — `misc_router`, `tasks_router`, `tools_router`, `entrust_router`, and\n  `entrust_router.webhook_router` (an unauthenticated sibling for the Onfido callback).\n* *Root path* — in dev the app is mounted under `/automatic-uw-tool-web-api`, so dev URLs look\n  like `/automatic-uw-tool-web-api/tasks/execute`. In `{prod-env}` it is mounted at `/`.\n  All paths on these pages are shown without the dev prefix.\n* *Interactive docs* — `/docs` and `/redoc` are enabled in dev and disabled in `{prod-env}`\n  (both set to `None` by `settings_setup.py`).\n\n== Authentication\n\nEvery `/tasks`, `/tools` and `/entrust/sessions` route declares\n`dependencies=[Depends(JWTAuthorizer)]`. `JWTAuthorizer`:\n\n. reads the `x-session-token` header (`APIKeyHeader`, `auto_error=False`),\n. validates it via `GET \\{PAYABILITY_INTERNAL_API_URL}/internal/token/validate`,\n. decodes the JWT *without signature verification* to read `login_key`,\n. exposes `get_source()`, `get_login_key()`, `get_login_id()`, `get_supplier_key()`.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Status | Cause\n\n| `400` | The auth response has no `source` claim.\n| `401`, `404` | Propagated from the auth API (`400`/`401`/`404` are passed through).\n| `409` | A `KeyError` while reading the auth response (a required claim is missing).\n| `500` | Anything else, including auth-API 5xx.\n|===\n\nUnauthenticated routes: `GET /health-check` and `POST /entrust/webhook` — the latter is trusted\nby HMAC signature instead. See xref:components/auth-permissions.adoc[].\n\n== Authorization\n\nTwo independent gates:\n\n*Source* (`ProcessSource`, from the token): `internal`, `supplier_dashboard` or `onboarding`.\nSeveral endpoints are internal-source-only regardless of permissions; supplier-dashboard callers\nare always scoped to their own `supplier_key`.\n\n*Permissions* (`BaseEndpointHandler.check_permissions`): looked up per `login_key` through the\nPayability internal API. `INT_SUPER_USER` is appended to every check as a fallback, and\n`SUPPLIER_DASHBOARD` callers skip the check entirely. An auth-API failure during the check\nanswers `500`; a caller without any listed perm gets `403`.\n\n== Route table\n\n[cols=\"1,2,2,3\",options=\"header\"]\n|===\n| Method | Path | Handler | Auth and scope\n\n| `GET`\n| `/health-check`\n| inline\n| none\n\n| `POST`\n| `/tasks/execute`\n| `ExecuteTaskEndpointHandler`\n| JWT; internal needs `DEV`/`FINANCE`; supplier dashboard scoped to its own `supplier_key`\n\n| `GET`\n| `/tasks/result`\n| `GetTaskResultEndpointHandler`\n| JWT; *internal source only*; `detailed=true` additionally needs `DEV`/`FINANCE`/`REST_UW_ACCESS`\n\n| `GET`\n| `/tasks/\\{correlation_id}`\n| `GetTaskStatusEndpointHandler`\n| JWT; *internal source only*\n\n| `DELETE`\n| `/tasks/result`\n| `DeleteTaskResultsEndpointHandler`\n| JWT; *internal source only*\n\n| `PUT`\n| `/tasks/\\{correlation_id}/final_statuses`\n| `UpdateFinalStatusEndpointHandler`\n| JWT; *internal source only*\n\n| `GET`\n| `/tools`\n| `GetToolsEndpointHandler`\n| JWT (any source)\n\n| `POST`\n| `/entrust/sessions`\n| `CreateEntrustSessionEndpointHandler`\n| JWT; internal needs `DEV`/`FINANCE`; supplier dashboard scoped to itself; onboarding self-service\n\n| `POST`\n| `/entrust/webhook`\n| `HandleEntrustWebhookEndpointHandler`\n| none — Onfido `X-SHA2-Signature` HMAC\n|===\n\nDetails: xref:api/tasks.adoc[], xref:api/tools.adoc[], xref:api/entrust.adoc[].\n\n== `GET /health-check`\n\nUnauthenticated liveness probe used by the ALB target group.\n\n[source,python]\n----\n@router.get(path='/health-check', description='Health Check')\ndef health_check():\n    return {'status': 'OK'}\n----\n\nResponse: `{\"status\": \"OK\"}`.\n\n== Response conventions\n\n* Success responses are pydantic models (xref:api/schemas.adoc[]) or a `JSONResponse` built\n  from `jsonable_encoder`.\n* Controlled errors use the envelope `{\"code\": <status>, \"status\": \"<reason phrase>\",\n  \"message\": \"<text>\"}` from `BaseEndpointHandler.build_json_response`.\n* Domain errors (`TaskExecutionError` subclasses) serialize as\n  `{\"error_class\": \"...\", \"error_body\": {...}}` with status `400`.\n* Some handlers return `{\"error_message\": \"...\"}` for unexpected exceptions (`400` or `500`\n  depending on the handler).\n\n== Handler → source file\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | File\n\n| `POST /tasks/execute` | `interface/web_api/endpoint_handling/execute_task.py`\n| `GET /tasks/result` | `interface/web_api/endpoint_handling/get_task_result.py`\n| `GET /tasks/\\{id}` | `interface/web_api/endpoint_handling/get_task_status.py`\n| `DELETE /tasks/result` | `interface/web_api/endpoint_handling/delete_task_results.py`\n| `PUT /tasks/\\{id}/final_statuses` | `interface/web_api/endpoint_handling/update_final_status.py`\n| `GET /tools` | `interface/web_api/endpoint_handling/get_tools.py`\n| `POST /entrust/sessions` | `interface/web_api/endpoint_handling/create_entrust_session.py`\n| `POST /entrust/webhook` | `interface/web_api/endpoint_handling/handle_entrust_webhook.py`\n| Routers | `interface/web_api/router/{misc,tasks,tools,entrust}_router.py`\n| Base, schemas, logging | `interface/web_api/endpoint_handling/base.py`, `interface/web_api/schemas.py`, `interface/web_api/web_request_logger.py`\n|===\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"api/tasks.adoc","title":"Task endpoints","description":"Execute a process, poll it, read or purge its results, and override its verdict.","url":"https://docx.payability.com/automatic-uw-tool/api/tasks.html","navOrder":11,"source":"= Task endpoints\n:navtitle: Task endpoints\n:description: Execute a process, poll it, read or purge its results, and override its verdict.\n\nAll routes below live on `tasks_router` (prefix `/tasks`) and require a valid\n`x-session-token`. Paths omit the dev `root_path` prefix.\n\n[#execute-task]\n== `POST /tasks/execute`\n\nStarts an underwriting process: runs its *request* step synchronously and enqueues the *handle*\nstep on SWF.\n\nRequest body — an untyped `dict`, validated in the handler:\n\n[source,json]\n----\ninclude::example$execute-task-request.json[]\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `task_name`\n| A process name *or* a task-step name; resolved by `ProcessFactory.identify_process_name` (lower-cased). Also accepts the special Entrust reprocess value — see <<entrust-reprocess>>.\n\n| `task_input`\n| Process-specific fields; `supplier_key` is always required. See xref:processes/index.adoc[].\n\n| `force_new_execution`\n| Truthy values (`true`, `\"true\"`, `\"True\"`, `1`, `\"1\"`) bypass dedup. Internal callers only.\n|===\n\nBehaviour:\n\n* *Permissions* — internal callers need `DEV`, `FINANCE` or `INT_SUPER_USER`.\n* *Source scoping* — `SUPPLIER_DASHBOARD` requests must have\n  `task_input.supplier_key == JWT supplier_key`, else `400`.\n* *Dedup* — an existing non-`failed` execution of the same process whose stored `input` equals\n  this `task_input` returns that `correlation_id` instead of starting a new run.\n* *Rate limit* — `SUPPLIER_DASHBOARD` is capped at 3 executions per supplier + process → `429`.\n* *Source allow-list* — the request task's `get_available_process_sources()` decides; otherwise\n  `403`.\n* *Input* — a missing field raises `KeyError` → `400` naming the field; a failed input validator\n  → `400` with `{\"error_class\": \"ValidationError\", \"error_body\": {\"incorrect_fields\": {…}}}`.\n* *Workflow already running* — `CannotRunWorkflow` → `400` with `{\"error_class\":\n  \"CannotRunWorkflow\", \"error_body\": {\"reason\": …}}` (\"Task already running\").\n\nResponse — `ExecuteTaskResponse`:\n\n[source,json]\n----\n{ \"correlation_id\": \"3f6c1b64-8f0a-4d2b-9c2e-9f1a5f7c1d22\" }\n----\n\nSystems touched: `TaskExecutionTable`, `ToolTable`, B2B Task Manager (SWF start).\n\n[#entrust-reprocess]\n=== Special case: reprocess the latest Entrust run\n\n`task_name = \"entrust/handle_identity_document_validation_response\"` does not go through the\nprocess machinery — that task has no synchronous request step and is normally fired by the\nOnfido webhook. Requested here it becomes an admin *reprocess* action:\n\n. Requires `task_input.supplier_key` and `task_input.person_id` (missing `person_id` → `400`).\n. Internal only — `SUPPLIER_DASHBOARD` → `403`.\n. Finds the supplier's newest `validate_identity_document` row (by `create_ts`) on the\n  `supplier_key-process_name-index` GSI whose `input` carries a `workflow_run_id` and matching\n  `person_id`, ignoring `purged` rows. Au10tix rows (which carry `document_id`) are skipped.\n  Nothing found → `404`.\n. *Force-resets the guards* so the rerun really redoes the work: `status` → `pending` and\n  `documents_uploaded`, `documents_approved`, `uploaded_document_ids` are dropped from\n  `additional_data`.\n. Re-fires the SWF activity with `task_correlation_id = workflow_run_id` and a *unique*\n  workflow id (`… - reprocess - \\{uuid}`) so SWF does not dedup it against the original\n  delivery. `CannotRunWorkflow` → `409`.\n\nResponse: `{\"correlation_id\": \"<workflow_run_id>\"}`.\n\n[WARNING]\n====\nA reprocess re-archives the run, re-archives every existing identity document, re-uploads the\ncaptured files and (on an approved run) re-approves them. Expect a *new* set of document ids in\nthe supplier's document store.\n====\n\n[#get-task-result]\n== `GET /tasks/result`\n\nFetches the outcome of the latest finished execution of a process for one supplier + person.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Query parameter | Notes\n\n| `process_name` | Required. Process name or step name.\n| `supplier_key` | Required.\n| `person_id` | Required.\n| `detailed` | Optional; only the literal `\"true\"` or `\"True\"` counts as true.\n|===\n\n* *Internal source only* — any other source → `403`.\n* Selects the newest `processed` *or* `failed` execution for that person\n  (`get_latest_processed_task`); none → `404`.\n* The final status is read with `get_latest_by_correlation_id`, defaulting to `{}` when absent.\n* Unexpected exceptions → `500 {\"error_message\": \"...\"}`.\n\nNon-detailed response — `TaskResult` with just the persisted task output:\n\n[source,json]\n----\n{ \"result\": { \"raw_result_s3_path\": \"...\" }, \"timestamp\": \"2026-08-12T09:15:11.204812\" }\n----\n\nDetailed response additionally reads the archived JSON from S3\n(`additional_data.full_result_s3_path`; a `NoSuchKey` degrades to `{}`) and returns `status`,\n`final_status`, `default_result_status` and `requested_by`.\n\nPermission nuance for `detailed=true`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Caller | Result\n\n| Has `DEV`, `FINANCE`, `REST_UW_ACCESS` or `INT_SUPER_USER`\n| Full payload.\n\n| Lacks them, process is `validate_personal_information`\n| Payload returned but *masked*: everything in `NormalizedCriteria` and in each `SearchRecord` other than `FirstName`/`LastName` becomes `********`.\n\n| Lacks them, any other process\n| `403`.\n|===\n\nSystems touched: `TaskExecutionTable`, `FinalStatusTable`, S3.\n\n[#get-task-status]\n== `GET /tasks/\\{correlation_id}`\n\nPolls a single task by its correlation id. *Internal source only* (`403` otherwise).\n\nResponse: `200` with `{\"status\": \"pending|processed|failed|purged\", …task output fields}` —\nthe stored `output` map is merged into the body. Unknown id → `404`; unexpected errors →\n`500 {\"error_message\": …}`.\n\nSystems touched: `TaskExecutionTable.get`.\n\n[#delete-task-results]\n== `DELETE /tasks/result`\n\nPurges archived S3 results for a process (data-deletion / GDPR). *Internal source only.*\n\n[source,json]\n----\n{\n  \"correlation_id\": \"optional — a fresh uuid4 is used when omitted\",\n  \"process_name\": \"validate_personal_information\",\n  \"supplier_key\": \"9b1c…\",\n  \"person_id\": \"44f2…\"\n}\n----\n\n`process_name`, `supplier_key` and `person_id` are required (`400` otherwise). `ProcessFactory`\nbuilds both steps of the process, and every step implementing `PurgableS3ObjectProducer` runs\n`purge()`: each `processed` execution for that person has its S3 object deleted and its row\nmarked `purged`.\n\nResponse — `DeleteTaskResponse`:\n\n[source,json]\n----\n{ \"correlation_id\": \"…\", \"deleted_files\": [\"thomson_reuters/kyc/supplier_key=…/…json\"] }\n----\n\n[NOTE]\n====\nOnly the three Thomson Reuters handle tasks implement `purge()`. Identity-document results\n(Au10tix and Entrust) are not deleted by this endpoint.\n====\n\nSystems touched: `TaskExecutionTable`, S3.\n\n[#update-final-status]\n== `PUT /tasks/\\{correlation_id}/final_statuses`\n\nManual underwriter override of a verdict. *Internal source only* (`403` otherwise).\n\n[source,json]\n----\n{ \"data\": { \"DocumentAuthenticity\": \"VERIFIED\", \"IDNameMatch\": \"NOT_VERIFIED\", \"IsDocumentExpired\": \"VERIFIED\", \"OverallQuality\": \"VERIFIED\" } }\n----\n\nValidation (`VALIDATION_FIELDS`, keyed by the task's `process_name`):\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Process | Allowed values | Keys\n\n| `validate_personal_information`\n| `MATCH`, `NO_MATCH`, `PENDING`\n| `City`, `FirstName`, `LastName`, `SSN`, `State`, `StreetName`, `StreetNumber`, `ZipCode`, `BirthDate`, `MiddleName`, `PhoneNumber` — every key present in the task's `match_result` must be supplied.\n\n| `validate_identity_document`\n| `VERIFIED`, `NOT_VERIFIED`, `PENDING`\n| `DocumentAuthenticity`, `IDNameMatch`, `IsDocumentExpired`, `OverallQuality` — all four required.\n\n| `download_personal_background_report`\n| `PASS`, `FAIL`, `ERROR`, `PENDING`\n| Top-level `access` / `advance`, each mapping the seven section names.\n|===\n\n`download_business_background_report` is not overridable (it stores no final status) — its\n`process_name` is rejected with a `400` listing the supported processes.\n\nBehaviour: unknown `correlation_id` → `404`; a validation failure → `400\n{\"error_message\": …}`; any other exception → `400`. On success the handler saves the final\nstatus if none existed, recomputes the tool status from the override (tiered logic for the\nbackground report, `VERIFIED` only when no `NOT_VERIFIED`/`NO_MATCH`/`PENDING` value is\npresent otherwise), updates `ToolTable`, then updates `FinalStatusTable` with\n`updated_by = login_id`. Both writes append a changelog row.\n\nResponse: `200 {\"correlation_id\": \"…\"}`.\n\nSystems touched: `TaskExecutionTable`, `FinalStatusTable`, `ToolTable`, `ChangelogTable`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"api/tools.adoc","title":"Tool endpoints","description":"List the verification tools and their statuses for a supplier owner.","url":"https://docx.payability.com/automatic-uw-tool/api/tools.html","navOrder":12,"source":"= Tool endpoints\n:navtitle: Tool endpoints\n:description: List the verification tools and their statuses for a supplier owner.\n\n== `GET /tools`\n\nLists every verification *tool* and its current status for one supplier + person. This is the\nendpoint the admin UI renders as the underwriting checklist.\n\nRequires a valid `x-session-token`; any source may call it.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Query parameter | Notes\n\n| `supplier_key` | Required, UUID (regex-validated by FastAPI → `422` on a bad shape).\n| `person_id` | Required, UUID (regex-validated).\n|===\n\nBehaviour:\n\n* `ToolTable.get_tools_and_create_missing` returns the supplier/person rows and *creates any\n  missing ones* with status `NOT_RUN`. The auto-created set is `ToolTable.SUPPORTED_TOOLS`:\n  `AU10TIX`, `ENTRUST`, `THOMSON_REUTERS_CLEAR_ID`,\n  `THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT`.\n* *Stale-pending detection:* a tool that has been `PENDING` for more than\n  `TASK_TIMEOUT_IN_SECONDS` (300 s) is reported as `ERROR` with\n  `error_message = \"Task Timeout\"`, and that transition is persisted back to `ToolTable`.\n* Each row gets a display `name` from `ToolType[type].value`, and the list is sorted by that\n  name.\n\nResponse — `List[Tool]`:\n\n[source,json]\n----\ninclude::example$get-tools-response.json[]\n----\n\nEvery mutation (creation, timeout flip) also appends a `TOOL` row to `ChangelogTable`.\n\nSystems touched: `ToolTable`, `ChangelogTable`.\n\n== Tool vocabulary\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `ToolType` (storage key) | Display name\n\n| `THOMSON_REUTERS_CLEAR_ID` | Thomson Reuters Clear ID (KYC Check)\n| `THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT` | Thomson Reuters Personal Background Report\n| `AU10TIX` | Au10tix\n| `ENTRUST` | Entrust Identity Verification\n|===\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `ToolStatus` | Meaning\n\n| `NOT_RUN` | The row exists but the process has never run.\n| `PENDING` | A request step (or an Entrust session) started; awaiting the async result. Older than 300 s is surfaced as `ERROR`.\n| `ERROR` | The task failed, or the pending window expired.\n| `NOT_VERIFIED` | A terminal negative verdict.\n| `VERIFIED` | A terminal positive verdict.\n| `VERIFIED_ACCESS`, `VERIFIED_ADVANCE`, `VERIFIED_ACCESS_AND_ADVANCE` | Tiered verdicts, personal background report only.\n|===\n\nThere is deliberately no tool for `download_business_background_report`.\n\nSee xref:data/dynamodb.adoc#tool-table[the Tool table] for the stored item shape and its known\n`scan`-based access pattern.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"api/entrust.adoc","title":"Entrust endpoints","description":"Create a hosted Entrust identity-verification session and receive Onfido completion callbacks.","url":"https://docx.payability.com/automatic-uw-tool/api/entrust.html","navOrder":13,"source":"= Entrust endpoints\n:navtitle: Entrust endpoints\n:description: Create a hosted Entrust identity-verification session and receive Onfido completion callbacks.\n\nTwo surfaces on `entrust_router`: an authenticated session endpoint and an unauthenticated,\nsignature-verified webhook. The business flow behind them is in\nxref:processes/validate-identity-document-entrust.adoc[].\n\ninclude::partial$entrust-coexistence.adoc[]\n\n[#create-session]\n== `POST /entrust/sessions`\n\nCreates (or reuses) an Entrust Identity Verification session for a supplier owner and returns\nthe hosted verification URL.\n\nRequest body — `CreateEntrustSessionRequest`; both fields are optional at the HTTP layer because\nthe requirement depends on the caller:\n\n[source,json]\n----\n{ \"supplier_key\": \"9b1c4d3e-6f2a-4b8c-9d0e-1f2a3b4c5d6e\", \"person_id\": \"44f2a1b0-7c3d-4e5f-8a9b-0c1d2e3f4a5b\" }\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Source | Identity resolution\n\n| `supplier_dashboard`\n| Self-service only. The body may be omitted entirely — `supplier_key` comes from the token's `supplier_key` and `person_id` from its `login_key`. If a `supplier_key` *is* sent it must match the token, else `400`.\n\n| `internal`\n| Acts on someone else's behalf, so `supplier_key` *and* `person_id` are both required (`400` otherwise). Needs `DEV`, `FINANCE` or `INT_SUPER_USER`.\n\n| `onboarding`\n| Self-service origin used by the supplier onboarding flow; like `internal` it must supply both fields, but it needs no permissions.\n|===\n\nBehaviour:\n\n* *Prior-run lookup* — reads the supplier's `validate_identity_document` executions from the GSI\n  and keeps only Entrust rows (those whose `input` carries an `applicant_id`) for this\n  `person_id`, so historical Au10tix runs never affect dedup or rate limiting.\n* *Dedup* — if one of those rows is `pending` and its `additional_data.hosted_link` is still\n  valid (`link_expires_at` in the future, or absent), that URL, `workflow_run_id` and\n  `sdk_token` are returned instead of minting a new run. An unparseable expiry is treated as\n  stale.\n* *Rate limit* — `SUPPLIER_DASHBOARD` and `ONBOARDING` are capped at 3 sessions per person →\n  `429`. `INTERNAL` is unlimited.\n* *Name lookup* — `PayabilityInternalAPI.get_supplier_info(detailed=True)`, then the owner whose\n  `loginKey == person_id`. Middle names are stripped from `firstName`. Unknown supplier\n  (`400`/`404` upstream) → `404`; missing owner or missing name → `404`; other upstream failures\n  or connection errors → `502`.\n* *Session creation* — `create_applicant` then `create_workflow_run` against\n  `ENTRUST_WORKFLOW_ID`, tagged with `custom_data = {\"supplier_key\": …}`. A 4xx from Onfido is\n  passed through with its status and the parsed upstream body under `entrust_error`; a 5xx or\n  connection error becomes `502`.\n* *Persistence* — `TaskExecutionTable.save(task_id=workflow_run_id, …, status=pending)` with\n  `input = {supplier_key, person_id, applicant_id, workflow_run_id}` and\n  `additional_data = {hosted_link, link_expires_at, sdk_token}`, then\n  `ToolTable.update(ENTRUST, PENDING, correlation_id=workflow_run_id)`.\n* *Downstream syncs* — HubSpot `entrust_verification_status = in_progress` and a supplier event\n  (`status = in_progress`, `tool = ENTRUST`) to the internal EventLogger. Both are best effort;\n  neither can fail the request. See xref:components/hubspot.adoc[] and\n  xref:components/event-logger.adoc[].\n\nResponse — `EntrustSessionResponse`:\n\n[source,json]\n----\ninclude::example$entrust-session-response.json[]\n----\n\nA metric `create_entrust_session_endpoint_time_taken` is emitted on every call, success or\nfailure.\n\nSystems touched: Payability internal API, Entrust/Onfido, `TaskExecutionTable`, `ToolTable`,\n`ChangelogTable`, HubSpot, EventLogger.\n\n[#webhook]\n== `POST /entrust/webhook`\n\nReceives Onfido workflow-run completion callbacks. Unauthenticated by design — Onfido has no\nPayability JWT — so it lives on a separate `webhook_router` without the `JWTAuthorizer`\ndependency and establishes trust by signature instead.\n\nThe handler is deliberately thin: verify, correlate, enqueue. All slow work (Onfido re-read, S3\narchive, document download and re-upload, verdict) happens in the SWF task\n`entrust/handle_identity_document_validation_response`, mirroring how the Au10tix request task\nschedules its response task.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Check | Outcome\n\n| `X-SHA2-Signature` == `HMAC-SHA256(raw_body, ENTRUST_WEBHOOK_TOKEN)` (hex, `compare_digest`)\n| Missing header, unset secret or mismatch → `401 {\"error_message\": \"Invalid signature\"}`\n\n| Body parses as JSON\n| Otherwise `400 {\"error_message\": \"Malformed JSON body\"}`\n\n| `payload.resource_type == \"workflow_run\"` and `payload.action == \"workflow_run.completed\"`\n| Anything else → `200 {\"status\": \"ignored\"}` so Onfido stops retrying\n\n| `payload.object.id` present\n| Missing → `200 {\"status\": \"ignored\"}`\n\n| `TaskExecutionTable.get(workflow_run_id)` exists\n| Missing row (session never minted or purged) → `200 {\"status\": \"ignored\"}`\n\n| `B2BTaskManager.start_swf` succeeds\n| `200 {\"status\": \"accepted\"}`. `CannotRunWorkflow` (a concurrent delivery already scheduled it) is also `200 accepted`. Any other exception → `500` so Onfido retries.\n|===\n\nThe scheduled activity carries\n`task_input = {supplier_key, person_id, applicant_id, workflow_run_id}` and\n`task_correlation_id = workflow_run_id`, with the deterministic workflow id\n`Automatic UW Tool - Entrust - \\{workflow_run_id}`.\n\nThe raw request body must be read untouched (`await request.body()`) because the HMAC is\ncomputed over the exact received bytes.\n\nSystems touched: `TaskExecutionTable` (read only), SWF via the B2B Task Manager.\n\n== Configuring the webhook in Onfido\n\n. Point the Onfido webhook at `POST https://<host>/entrust/webhook` (dev hosts include the\n  `/automatic-uw-tool-web-api` root path).\n. Subscribe to the `workflow_run.completed` event.\n. Use the same shared secret that is stored as `ENTRUST_WEBHOOK_TOKEN` in the\n  `automatic_uw_tool` secret — see xref:reference/settings.adoc[].\n. The workflow itself is referenced by `ENTRUST_WORKFLOW_ID`; changing the Studio workflow means\n  changing that setting in `settings_setup.py`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"api/schemas.adoc","title":"Schemas","description":"The pydantic request and response models of the HTTP layer.","url":"https://docx.payability.com/automatic-uw-tool/api/schemas.html","navOrder":14,"source":"= Schemas\n:navtitle: Schemas\n:description: The pydantic request and response models of the HTTP layer.\n\n`interface/web_api/schemas.py` defines the HTTP contract FastAPI uses for validation and\nOpenAPI generation. The models are intentionally decoupled from the DynamoDB item shapes so\nstorage changes do not leak into the public API.\n\n== Response models\n\n[source,python]\n----\nclass ExecuteTaskResponse(BaseModel):\n    correlation_id: str\n\n\nclass DeleteTaskResponse(BaseModel):\n    correlation_id: str\n    deleted_files: List[str]\n\n\nclass TaskResult(BaseModel):\n    result: dict\n    timestamp: str\n    status: Optional[str] = Field(None)\n    final_status: Optional[dict] = Field({})\n    default_result_status: Optional[dict] = Field(None)\n    requested_by: Optional[str] = Field(None)\n\n\nclass Tool(BaseModel):\n    supplier_key: UUID\n    person_id: UUID\n    type: str\n    status: ToolStatus\n    name: str\n    correlation_id: Optional[UUID] = Field(None)\n    updated_by: str\n    updated_at: Optional[datetime] = Field(None)\n    created_by: str\n    created_at: datetime\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Model | Used by\n\n| `ExecuteTaskResponse` | xref:api/tasks.adoc#execute-task[`POST /tasks/execute`] (including the Entrust reprocess path)\n| `DeleteTaskResponse` | xref:api/tasks.adoc#delete-task-results[`DELETE /tasks/result`]\n| `TaskResult` | xref:api/tasks.adoc#get-task-result[`GET /tasks/result`] — the optional fields are populated only on a detailed, permitted read\n| `Tool` | xref:api/tools.adoc[`GET /tools`] (as `List[Tool]`)\n|===\n\n`Tool.status` is the `ToolStatus` enum, so an unknown stored value would fail response\nvalidation — statuses are always written through the enum.\n\n== Entrust models\n\n[source,python]\n----\nclass CreateEntrustSessionRequest(BaseModel):\n    supplier_key: Optional[str] = Field(None)\n    person_id: Optional[str] = Field(None)\n\n\nclass EntrustSessionResponse(BaseModel):\n    url: str\n    workflow_run_id: str\n    sdk_token: Optional[str] = Field(None)\n    link_expires_at: Optional[str] = Field(None)\n----\n\nBoth request fields are optional at the HTTP layer on purpose: supplier-dashboard callers are\nderived entirely from the JWT and may omit the body, while internal and onboarding callers must\nsupply the target owner. The per-source requirement is enforced inside the handler so the\nfailure is a clear `400` rather than a generic `422`.\n\n== Untyped bodies\n\n`POST /tasks/execute`, `DELETE /tasks/result` and `PUT /tasks/\\{id}/final_statuses` accept\n`payload: dict = Body()` and validate manually in the handler. That predates the pydantic\nmodels above; new endpoints must define a `BaseModel` instead — see\nxref:contributing/python-standards.adoc[].\n\n== Auth model\n\n`components/auth/schemas.py`:\n\n[source,python]\n----\nclass AuthValidateResponse(BaseModel):\n    login_id: str\n    source: ProcessSource\n    login_key: Optional[str] = Field(None)\n    supplier_key: Optional[str] = Field(None)\n----\n\n`login_id` and `source` come from the auth API response, `login_key` from the decoded JWT, and\n`supplier_key` is present for supplier-dashboard tokens. See\nxref:components/auth-permissions.adoc[].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"events/swf-orchestration.adoc","title":"SWF orchestration","description":"How the asynchronous handle step is scheduled, polled, executed and reported.","url":"https://docx.payability.com/automatic-uw-tool/events/swf-orchestration.html","navOrder":15,"source":"= SWF orchestration\n:navtitle: SWF orchestration\n:description: How the asynchronous handle step is scheduled, polled, executed and reported.\n\nThe handle step of every process runs as an AWS Simple Workflow *activity*. The Web API never\ncalls SWF directly — it goes through the shared B2B Task Manager service.\n\n== Start side: Web API → B2B Task Manager → SWF\n\nAn `InitialTask` (or the Entrust webhook handler) calls `B2BTaskManager.start_swf(...)`. The\nworkflow input embeds the *handle* task name plus the correlation id:\n\n[source,json]\n----\ninclude::example$swf-workflow-input.json[]\n----\n\n`start_swf` injects `gci` from settings and posts to\n`POST \\{B2B_TASK_MANAGER_URL}/executeWorkflow`:\n\n[source,python]\n----\ndef start_swf(self, domain, workflow_type, workflow_type_version, workflow_identifier, input_body):\n    input_body.update({'gci': settings.B2B_TASK_MANAGER_GCI})\n\n    response = self.__session.post(\n        url='{}/executeWorkflow'.format(settings.B2B_TASK_MANAGER_URL),\n        headers={},\n        params={\n            'domain': domain,\n            'workflowTypeName': workflow_type,\n            'workflowTypeVersion': workflow_type_version,\n            'taskList': 'default',\n            'workflowId': workflow_identifier,\n            'input': json.dumps(input_body)\n        }\n    )\n----\n\nA non-2xx raises `HTTPError`. A 2xx that carries `errorMessages` or `exceptionMessages` raises\n`CannotRunWorkflow`, whose `str()` collapses the SWF\n`WorkflowExecutionAlreadyStartedFault` into `\"Task already running\"`. Because\n`CannotRunWorkflow` is a `TaskExecutionError`, `task_failure_handling` re-raises it untouched\nand the API answers `400` (or `409` for the Entrust reprocess path) rather than marking the task\nfailed.\n\n=== Workflow identifiers\n\nThe `workflowId` is the SWF dedup key. The choices in this repository are deliberate:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Trigger | `workflowId`\n\n| Au10tix identity document | `Automatic UW Tool - \\{supplier_key} - Validate identity document.`\n| Personal information (KYC) | `Automatic UW Tool - \\{supplier_key} - Validate personal informations.`\n| Personal background report | `Automatic UW Tool - \\{supplier_key} - Download personal background report.`\n| Business background report | `Automatic UW Tool - \\{supplier_key} - Download business background report.`\n| Entrust webhook | `Automatic UW Tool - Entrust - \\{workflow_run_id}` — deterministic, so concurrent redeliveries dedup\n| Entrust manual reprocess | `Automatic UW Tool - Entrust - \\{workflow_run_id} - reprocess - \\{uuid4}` — unique, so the rerun is *not* deduped against the original\n|===\n\n== SWF coordinates\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Setting | Value\n\n| `SWF_WORKFLOW_TYPE` | `Update Amazon SC Earnings`\n| `SWF_WORKFLOW_TYPE_VERSION` | `1.1`\n| `SWF_DOMAIN` | `B2B` in `{prod-env}`, otherwise the environment name\n| `SWF_AUTOMATIC_UW_TOOL_TASK_LIST` | `{swf-task-list}`\n| `SWF_APP_THREADS` | `5`\n| `B2B_TASK_MANAGER_GCI` | `1` in `{prod-env}`; a hash of the env name in dev\n|===\n\nThe workflow *type* is shared platform-wide (hence the unrelated-sounding name) and its\ndefinition lives outside this repository. It schedules an activity onto `{swf-task-list}`,\nwhich this repository's worker polls.\n\n== Worker side (`swf_app.py`)\n\nAt startup the worker builds one `TaskFactory` with every client injected, one `SWFClient`, and\n`SWF_APP_THREADS` threads (named with `coolname` slugs) all running the same loop:\n\n[source,python]\n----\nwhile True:\n    activity_task = swf_client.poll_activity_task(\n        domain=settings.SWF_DOMAIN,\n        identity=settings.SWF_WORKFLOW_TYPE,\n        task_list=settings.SWF_AUTOMATIC_UW_TOOL_TASK_LIST\n    )\n\n    task = None\n    task_token = activity_task['taskToken']\n    process_start_time = time.time()\n    try:\n        task_definition = json.loads(activity_task['input'])\n        task = task_factory.build(task_definition)\n        task_input = task.build_task_input(task_definition.get('task_input', {}))\n        execution_output = task.execute(task_input)\n    except Exception as exception:\n        sentry_sdk.capture_exception()\n        swf_client.notify_about_task_failure(task_token=task_token, reason=exception)\n        if task:\n            send_metric(..., name=f'{task.get_process_name()}_failure', value=1.00, unit=CloudWatchMetricUnit.NONE)\n    else:\n        swf_client.notify_about_task_success(task_token=task_token, result=execution_output.to_dict())\n        send_metric(..., name=f'{task.get_process_name()}_success_time_taken', value=round_time_diff(...))\n----\n\n`SWFClient.poll_activity_task` loops until a task token appears, swallowing\n`ReadTimeoutError` and `ConnectionClosedError` (long-poll timeouts are normal).\n\n=== Reporting failure safely\n\n`RespondActivityTaskFailed` caps `reason` at 256 characters and `details` at 32768. An\nover-long `reason` makes the API call itself raise, which would leave the activity un-failed\nuntil its start-to-close timeout. `SWFClient` therefore derives a short reason\n(`error` → `error_message` → `error_type` → the raw payload) and puts the full JSON payload in\n`details`, truncating both.\n\nAn exception raised *before* `task_factory.build` succeeds (bad JSON, unknown `task_name`) means\n`task` is `None`, so the failure metric is skipped — the failure is still reported to SWF and\nSentry.\n\n== Metrics emitted here\n\n* `\\{process_name}_failure` — count, on any exception.\n* `\\{process_name}_success_time_taken` — seconds, on success.\n\n`process_name` comes from `task.get_process_name()`, so all Entrust and Au10tix activity is\nreported under `validate_identity_document`. See xref:operations/observability.adoc[].\n\n== Idempotency expectations\n\nSWF may deliver an activity more than once, and Onfido may redeliver a webhook. Handle tasks\nmust therefore tolerate replay:\n\n* Entrust: an already-`processed` row short-circuits; `documents_uploaded` /\n  `documents_approved` guard the document mutations; `FinalStatusTable` writes are upserts.\n* Thomson Reuters and Au10tix tasks reload state by `correlation_id` and overwrite their own\n  row, so a replay re-does vendor work but converges on the same result — with the caveat that\n  it also re-emits `ProcessFinishedEvent` and appends a new `FinalStatusTable` version.\n\nNew async work must follow xref:contributing/python-standards.adoc[the idempotency rule].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"events/eventbridge-events.adoc","title":"EventBridge events","description":"The outbound event catalog, payload shapes, and how to add or consume an event.","url":"https://docx.payability.com/automatic-uw-tool/events/eventbridge-events.html","navOrder":16,"source":"= EventBridge events\n:navtitle: EventBridge events\n:description: The outbound event catalog, payload shapes, and how to add or consume an event.\n\nOutbound notifications are published with `EventBridgeClient.put_event()` to the bus\n`EVENT_BUS_NAME` (`prod_workers` in `{prod-env}`, the environment name in dev). A non-zero\n`FailedEntryCount` raises `ConnectionError`, which fails the task.\n\n[source,python]\n----\ndef put_event(self, event: EventBridgeEvent):\n    event_body = event.get_body()\n    result = self.client.put_events(Entries=[event_body])\n\n    if isinstance(result, dict) and result.get('FailedEntryCount', 1):\n        raise ConnectionError('Cannot put event to Event Bridge.')\n----\n\n== Event catalog\n\n[cols=\"2,2,2,2,2\",options=\"header\"]\n|===\n| Event class | `Source` | `DetailType` | Emitted by | When\n\n| `ProcessFinishedEvent`\n| `pyb.underwriting.events`\n| the uppercased process name\n| Au10tix ID-doc, Entrust ID-verification, personal-info and personal-background handle tasks\n| the process completed with a terminal result\n\n| `ThomsonReutersDataMatchEvent`\n| `pyb.internal.admin.events`\n| `THOMSON_REUTERS_DATA_MATCH`\n| personal-info (KYC) handle task\n| every PII field matched\n\n| `ThomsonReutersDataNotMatchEvent`\n| `pyb.internal.admin.events`\n| `THOMSON_REUTERS_DATA_NOT_MATCH`\n| personal-info (KYC) handle task\n| one or more PII fields did not match (carries `data_points`)\n\n| `ThomsonReutersNotWorkingEvent`\n| `pyb.internal.admin.events`\n| `THOMSON_REUTERS_NOT_WORKING`\n| personal-info (KYC) handle task\n| the CLEAR call failed — the platform may let the customer proceed without the check\n|===\n\n`DetailType` values for `ProcessFinishedEvent` are therefore `VALIDATE_IDENTITY_DOCUMENT`,\n`VALIDATE_PERSONAL_INFORMATION` and `DOWNLOAD_PERSONAL_BACKGROUND_REPORT`.\n\n[NOTE]\n====\n`download_business_background_report` emits *no* events — it only archives raw results. The\nEntrust flow emits nothing while a run is under `review`; the event follows the later terminal\ncompletion.\n====\n\n== Payload shapes\n\nEvery event is a `put_events` entry whose `Detail` is a JSON *string* and always includes\n`project_name` (`{project-name}`). Consumers route on `Source` + `DetailType`.\n\n=== `ProcessFinishedEvent`\n\n[source,python]\n----\n{\n    'Source': 'pyb.underwriting.events',\n    'EventBusName': settings.EVENT_BUS_NAME,\n    'DetailType': process_name.upper(),\n    'Detail': json.dumps({\n        'project_name': settings.PROJECT_NAME,\n        'message': f'The process {process_name} is finished',\n        'supplier_key': task_input['supplier_key'],\n        'person_id': task_input['person_id'],\n        'result_s3_path': result_s3_path,\n        'status': TaskExecutionTable.PROCESSED_STATUS\n    })\n}\n----\n\n[source,json]\n----\ninclude::example$process-finished-event.json[]\n----\n\n`status` is always the literal `processed` — the event is only emitted on success. The\n`result_s3_path` points into the datalake (xref:data/s3-datalake.adoc[]).\n\n[IMPORTANT]\n====\n`task_input` must carry `supplier_key` and `person_id`, so a handle task has to pass the\nreloaded supplier data (not just `\\{correlation_id}`) when constructing the event. The Thomson\nReuters tasks pass the row's `input`; the Entrust task passes `input_.to_dict()`.\n====\n\n=== `ThomsonReutersDataNotMatchEvent`\n\n[source,python]\n----\n{\n    'Source': 'pyb.internal.admin.events',\n    'EventBusName': settings.EVENT_BUS_NAME,\n    'DetailType': 'THOMSON_REUTERS_DATA_NOT_MATCH',\n    'Detail': json.dumps({\n        'project_name': settings.PROJECT_NAME,\n        'message': 'Customer data is not a match with Thomson Reuters. '\n                   'Incorrect data points are {}'.format(data_points),\n        'task_input': task_input\n    })\n}\n----\n\n`data_points` is the list of mismatched field names (both `PARTIAL MATCH` and `NO MATCH` count\nas mismatched), rendered into `message`. The full `task_input` — including PII — travels inside\n`Detail`.\n\n=== `ThomsonReutersDataMatchEvent` and `ThomsonReutersNotWorkingEvent`\n\nSame shape (`Source: pyb.internal.admin.events`, `project_name`, `message`, `task_input`),\ndiffering only in `DetailType` and message text:\n\n* match — `\"All customer data is match\"`.\n* not working — `\"Due to error with Thomson Reuters, could not validate data. Allowing customer\n  to proceed without data check.\"`\n\n== Not on this bus: supplier events\n\nThe Entrust flow also emits *supplier events* to Payability's internal EventLogger service — a\nseparate, supplier-timeline channel, not EventBridge. It is best effort and carries the\nverification lifecycle status rather than a process completion. See\nxref:components/event-logger.adoc[].\n\n== Consuming these events\n\nConsumers live outside this repository. Subscribe an EventBridge rule on the bus for the\nrelevant `Source` and `DetailType`. The `pyb.underwriting.events` events are the primary\n\"a result is ready\" signal; the `pyb.internal.admin.events` events drive internal KYC review and\nnotification flows.\n\n== Adding an event\n\n. Add a class under `components/event_bridge/events/` extending `EventBridgeEvent` with a stable\n  `Source` and `DetailType`.\n. Emit it from the relevant handle task through the injected `event_bridge_client`.\n. Keep it idempotent-friendly: a replayed activity will re-emit it, so consumers must tolerate\n  duplicates.\n. Document it in the catalog above.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"data/dynamodb.adoc","title":"DynamoDB tables","description":"The four tables, their keys, item shapes, statuses and access patterns.","url":"https://docx.payability.com/automatic-uw-tool/data/dynamodb.html","navOrder":17,"source":"= DynamoDB tables\n:navtitle: DynamoDB tables\n:description: The four tables, their keys, item shapes, statuses and access patterns.\n\nFour tables. Names come from settings and are environment-suffixed outside `{prod-env}` (see\nxref:reference/settings.adoc[]); the clients that wrap them are described in\nxref:components/aws.adoc[].\n\n== Overview\n\n[cols=\"2,2,1,1,2\",options=\"header\"]\n|===\n| Table (setting) | Client | PK | Sort key | Role\n\n| `SUPPLIER_DATA_VALIDATION_TASK_TABLE_NAME`\n| `components/task_execution_table.py`\n| `task_id`\n| — (GSI `supplier_key-process_name-index`)\n| Async task lifecycle and audit log\n\n| `FINAL_STATUS_TABLE_NAME`\n| `components/aws/dynamodb/final_status_table.py`\n| `correlation_id`\n| `created_at`\n| Underwriting verdicts per execution\n\n| `TOOL_TABLE_NAME`\n| `components/aws/dynamodb/tool/tool_table.py`\n| `supplier_key`\n| `type`\n| Per-supplier/person tool state\n\n| `CHANGELOG_TABLE_NAME`\n| `components/aws/dynamodb/changelog_table.py`\n| `supplier_key`\n| `log_ts`\n| Append-only audit of verdict and tool changes\n|===\n\n`task_id` and `correlation_id` are the same value for a given execution.\n\ninclude::partial$correlation-id.adoc[]\n\n[#task-execution-table]\n== Task Execution Table\n\nTracks each execution from creation to completion. Written `pending` by request tasks (or the\nEntrust session endpoint) and updated by handle tasks and the purge flow.\n\n*Key:* PK `task_id`. *GSI:* `supplier_key-process_name-index` — every list query in the codebase\ngoes through it and auto-paginates on `LastEvaluatedKey`.\n\n[source,python]\n----\nitem = {\n    'task_id': task_id,\n    'supplier_key': supplier_key,\n    'input': input_,\n    'output': output,\n    'status': self.PENDING_STATUS,\n    'process_name': process_name,\n    'create_ts': datetime.utcnow().isoformat(),\n    'additional_data': additional_data,\n    'requested_by': requested_by,\n}\nif source:\n    item.update(source=source.value)\n----\n\n`update(task_id, item_data)` always stamps `update_ts` and rejects any `status` outside\n`TASK_STATUSES` with `RuntimeError('Status not allowed')`. Typical updates add `output`,\n`additional_data`, `default_result_statuses` and the S3 result path.\n\n*Statuses:* `pending` → `processed` | `failed` | `purged`.\n\n*Queries:*\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get(task_id)` | Direct read; raises `KeyError('Item')` when absent.\n| `get_by_supplier_key_and_process_name` | GSI query, auto-paginated.\n| `get_latest_processed_task(supplier_key, process_name, person_id)` | Newest `processed` *or* `failed` row for that person (by `create_ts`) — powers `GET /tasks/result`.\n| `get_latest_task(...)` | Newest non-`purged` row for that person.\n|===\n\n=== Au10tix rows\n\n`input = {supplier_key, person_id, document_id}`, `output = \\{raw_result_s3_path}`,\n`additional_data = \\{full_result_s3_path}`.\n\n=== Entrust rows\n\nEntrust reuses the `validate_identity_document` process name (keeping the GSI, dedup and admin\nreporting intact) but stores an Entrust-shaped row:\n\n[source,json]\n----\ninclude::example$task-execution-item-entrust.json[]\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Attribute | Notes\n\n| `task_id`\n| The Onfido `workflow_run_id`.\n\n| `input`\n| `{supplier_key, person_id, applicant_id, workflow_run_id}`. The presence of `applicant_id` / `workflow_run_id` is what distinguishes Entrust rows from Au10tix rows (which carry `document_id`) during coexistence.\n\n| `additional_data.hosted_link`, `.link_expires_at`, `.sdk_token`\n| Written at session creation; drive the dedup/reuse check.\n\n| `additional_data.documents_uploaded`\n| `true` once stale captures were archived and the fresh artifacts uploaded — guards against duplicate uploads on webhook redelivery.\n\n| `additional_data.uploaded_document_ids`\n| The ids the uploads returned, so the approved path can approve exactly that set.\n\n| `additional_data.documents_approved`\n| `true` once those ids were promoted to `approved` (approved verdict only).\n\n| `additional_data.document_actions`\n| `{existing_documents_archived, id_files_uploaded, liveness_files_uploaded, uploaded_total, approved}`; also copied onto `output`.\n\n| `additional_data.full_result_s3_path`\n| Set to the archived run JSON on the terminal update.\n\n| `output`\n| On the terminal update: `{raw_result_s3_path, workflow_run_id, applicant_id, workflow_id, workflow_version_id, status, dashboard_url, output, reasons, error, created_at, updated_at, document_actions}` — `None` values are omitted.\n|===\n\nA `review` verdict leaves the row `pending` (only guards and `document_actions` are persisted)\nuntil a later completion; a task-level failure sets `failed`.\n\n[#final-status-table]\n== Final Status Table\n\nThe underwriting *verdict* for one execution. Every write also appends a changelog row. Multiple\nversions per `correlation_id` are kept, sorted by `created_at`.\n\n*Key:* PK `correlation_id`, sort `created_at`.\n\n[source,python]\n----\nitem = {\n    'correlation_id': correlation_id,\n    'data': data,\n    'updated_by': created_by,\n    'updated_at': current_timestamp,\n    'created_by': created_by,\n    'created_at': current_timestamp\n}\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Process | `data` contents\n\n| `validate_identity_document`\n| `DocumentAuthenticity`, `IsDocumentExpired`, `IDNameMatch`, `OverallQuality` — each `VERIFIED` / `NOT_VERIFIED`\n\n| `validate_personal_information`\n| one key per PII field — `MATCH` / `NO_MATCH`\n\n| `download_personal_background_report`\n| `{access: {<Section>: PASS\\|FAIL\\|ERROR}, advance: {…}}`\n\n| `download_business_background_report`\n| *not written*\n|===\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `save` | `put_item` a new version; writes an `INIT` changelog row.\n| `update` | Reload the *first* version, replace `data`, stamp `updated_by`/`updated_at`, `put_item`, write an `UPDATE` changelog row.\n| `get_by_correlation_id` | The first (oldest) version; raises `NotFoundFinalStatus`.\n| `get_latest_by_correlation_id` | The last (newest) version.\n|===\n\n`created_by` is `automatic-uw-tool` for automated verdicts, `override` for the first\nmanually-created row, and the underwriter's `login_id` for `updated_by` on overrides.\n\n[#tool-table]\n== Tool Table\n\nPer-supplier, per-person status of each verification tool. Powers\nxref:api/tools.adoc[`GET /tools`].\n\n*Key:* PK `supplier_key`, sort `type` (the `ToolType` *name*).\n\n[source,python]\n----\nitem = {\n    'supplier_key': supplier_key,\n    'person_id': person_id,\n    'type': tool_type.name,\n    'status': ToolStatus.NOT_RUN.value,\n    'correlation_id': correlation_id,\n    'updated_by': created_by,\n    'updated_at': current_timestamp,\n    'created_by': created_by,\n    'created_at': current_timestamp\n}\n----\n\n`update()` sets `status`, `error_message`, `correlation_id`, `updated_by`/`updated_at` and falls\nback to `save()` when the row does not exist (upsert). `SUPPORTED_TOOLS` — `AU10TIX`,\n`ENTRUST`, `THOMSON_REUTERS_CLEAR_ID`, `THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT` — is the set\n`get_tools_and_create_missing` / `create_missing_tools` auto-create for a supplier/person.\n\n[WARNING]\n====\nBecause the sort key is `type` alone, a supplier with several owners has one row per\n`(supplier_key, type)` — `person_id` is a plain attribute. Lookups therefore use `scan` with\nfilters on `supplier_key` + `person_id` + `type` rather than a key query. This is a known hot\nspot; see xref:contributing/python-standards.adoc[].\n====\n\nStatus vocabulary and display names: xref:api/tools.adoc#tool-vocabulary[].\n\n[#changelog-table]\n== Changelog Table\n\nAppend-only audit trail of every mutation to final-status and tool rows, written automatically\nby `FinalStatusTable` and `ToolTable`.\n\n*Key:* PK `supplier_key`, sort `log_ts`.\n\n[source,python]\n----\nself.__table.put_item(Item={\n    'supplier_key': supplier_key,\n    'type': changelog_type.value,      # FINAL_STATUS | TOOL\n    'action': changelog_action.value,   # INIT | UPDATE\n    'data': data,                       # full snapshot of the row that changed\n    'log_ts': datetime.utcnow().isoformat(),\n    'app_version': version.__version__\n})\n----\n\n`app_version` comes from `version.py`, tying every change to a code version.\n\n== Relationships\n\n[source,mermaid]\n----\nerDiagram\n    TASK_EXECUTION ||--o| FINAL_STATUS : \"correlation_id (= task_id)\"\n    TASK_EXECUTION ||--o| S3_OBJECT : \"result path\"\n    TOOL }o--|| SUPPLIER : \"supplier_key + person_id\"\n    FINAL_STATUS ||--o{ CHANGELOG : \"audited\"\n    TOOL ||--o{ CHANGELOG : \"audited\"\n----\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"data/s3-datalake.adoc","title":"S3 datalake","description":"Where raw vendor responses are archived, the key conventions, and how they are deleted.","url":"https://docx.payability.com/automatic-uw-tool/data/s3-datalake.html","navOrder":18,"source":"= S3 datalake\n:navtitle: S3 datalake\n:description: Where raw vendor responses are archived, the key conventions, and how they are deleted.\n\nRaw vendor responses are archived as JSON in the datalake bucket `S3_DATALAKE_BUCKET`\n(`payability-datalake` in `{prod-env}`, `dev-payability-datalake` in dev) through\n`components/s3_client.py`.\n\n[source,python]\n----\ndef upload_json_data(self, data: dict, bucket: str, key: str):\n    with io.BytesIO(json.dumps(data).encode()) as f:\n        self.__s3_client.upload_fileobj(\n            Fileobj=f, Bucket=bucket, Key=key,\n            ExtraArgs={'ACL': 'bucket-owner-full-control'}\n        )\n----\n\nThe `bucket-owner-full-control` ACL matters: the datalake bucket lives in a different account\nfrom some writers, and downstream consumers must be able to read the objects.\n\n== Key conventions\n\nKeys are partitioned by supplier and person and suffixed with a UTC ISO-8601 timestamp, so a\nsupplier's history is retained rather than overwritten.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Process | Key pattern\n\n| `validate_identity_document` (Au10tix)\n| `au10tix/identity_document_validation/supplier_key=<sk>/person_id=<pid>/<timestamp>.json`\n\n| `validate_identity_document` (Entrust)\n| `entrust/identity_document_validation/supplier_key=<sk>/person_id=<pid>/<timestamp>.json`\n\n| `validate_personal_information`\n| `thomson_reuters/kyc/supplier_key=<sk>/person_id=<pid>/<timestamp>.person_search.json`\n\n| `download_personal_background_report`\n| `thomson_reuters/background_reports/supplier_key=<sk>/person_id=<pid>/person/<timestamp>.json`\n\n| `download_business_background_report`\n| `thomson_reuters/background_reports/supplier_key=<sk>/person_id=<pid>/business/<timestamp>.json`\n|===\n\n[IMPORTANT]\n====\nThe layout `<vendor>/<process>/supplier_key=<k>/person_id=<p>/<timestamp>.json` is part of the\nplatform contract — downstream consumers (for example `s3-files-provider`) parse these paths.\nChanging a prefix is a breaking change.\n====\n\n== What is stored\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Process | Payload\n\n| Au10tix\n| The raw `IdentityDocuments/Results/\\{id}` response (`CompletionStatus == 'Ok'`).\n\n| Entrust\n| The workflow-run summary merged with its hydrated `tasks` — each step's `input` and `output`, i.e. the full decision trail. If step hydration failed, the summary alone.\n\n| Personal information (KYC)\n| The parsed EIDV person-search result (`xmltodict` output of the CLEAR XML).\n\n| Personal / business background report\n| The parsed report response, fetched after the `204` polling loop finished.\n|===\n\nThe key is written back onto the task row (`output.raw_result_s3_path` and\n`additional_data.full_result_s3_path`) and is what the detailed\nxref:api/tasks.adoc#get-task-result[`GET /tasks/result`] reads via\n`S3Client.get_json_data` — a `NoSuchKey` degrades to an empty result rather than failing the\nrequest.\n\n== Deletion\n\nxref:api/tasks.adoc#delete-task-results[`DELETE /tasks/result`] drives the purge flow:\n`ProcessFactory` builds both steps of the process, and each step implementing\n`PurgableS3ObjectProducer` runs\n\n[source,python]\n----\ndef purge(self, supplier_key: str, person_id: str, s3_object_purger: S3ObjectPurger) -> List[str]\n----\n\nwhich, for every `processed` execution of that person, deletes\n`additional_data.full_result_s3_path` and marks the task row `purged`. The deleted keys are\nreturned to the caller.\n\nImplemented by the three Thomson Reuters handle tasks only. Identity-document archives (Au10tix\nand Entrust) are *not* purged by this endpoint — a data-deletion request covering them needs\nmanual work today.\n\n== Related storage outside the datalake\n\nThe Entrust flow also pushes the supplier's captured ID images, selfie and liveness media into\n*Payability's own document store* through the internal API — that is a separate system from the\ndatalake, and the one that feeds compliance review. See\nxref:components/payability-internal.adoc[] and\nxref:processes/validate-identity-document-entrust.adoc[].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/index.adoc","title":"Component layer","description":"The integration layer — every client that talks to something outside this process.","url":"https://docx.payability.com/automatic-uw-tool/components/index.html","navOrder":19,"source":"= Component layer\n:navtitle: Component layer\n:description: The integration layer — every client that talks to something outside this process.\n\nEverything under `components/` wraps an external system. Tasks\n(xref:domain-model.adoc[]) and endpoint handlers (xref:api/index.adoc[]) compose these\nclients; they never call `requests` or `boto3` directly.\n\n== Inventory\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Area | Modules | Page\n\n| Au10tix identity documents\n| `au10tix/`\n| xref:components/au10tix.adoc[]\n\n| Entrust / Onfido identity verification\n| `entrust/`\n| xref:components/entrust.adoc[]\n\n| HubSpot CRM sync\n| `hubspot/`\n| xref:components/hubspot.adoc[]\n\n| Supplier event stream (internal EventLogger)\n| `event_logger/`\n| xref:components/event-logger.adoc[]\n\n| Thomson Reuters CLEAR\n| `thomson_reuters/`\n| xref:components/thomson-reuters.adoc[]\n\n| AWS: DynamoDB, S3, SWF, EventBridge\n| `aws/`, `task_execution_table.py`, `s3_client.py`, `s3_purgable/`, `swf_client.py`, `event_bridge/`\n| xref:components/aws.adoc[]\n\n| Payability internal services\n| `payability_internal_api.py`, `pyb_documents.py`, `b2b_task_manager.py`\n| xref:components/payability-internal.adoc[]\n\n| Auth and permissions\n| `auth/`, `permissions/`, `process_source.py`\n| xref:components/auth-permissions.adoc[]\n\n| Cross-cutting helpers\n| `retry/`, `logger.py`, plus `utils/`\n| xref:components/utilities.adoc[]\n|===\n\n== AWS services used\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Service | Wrapped by | Documented in\n\n| DynamoDB | `aws/dynamodb/*`, `task_execution_table.py` | xref:data/dynamodb.adoc[]\n| S3 | `s3_client.py`, `s3_purgable/s3_object_purger.py` | xref:data/s3-datalake.adoc[]\n| SWF | `swf_client.py` (worker side), `b2b_task_manager.py` (start side) | xref:events/swf-orchestration.adoc[]\n| EventBridge | `event_bridge/*` | xref:events/eventbridge-events.adoc[]\n| Secrets Manager | `config_setup.py` (build time only) | xref:operations/configuration.adoc[]\n| CloudWatch | `utils/metric_utils.py` via `pyb-sdk` | xref:operations/observability.adoc[]\n|===\n\n== Where clients are instantiated\n\n*Worker* (`swf_app.py`) builds one `TaskFactory` at startup with every client injected, shared\nacross all five polling threads:\n\n[source,python]\n----\ntask_factory = TaskFactory(\n    s3_client=S3Client(),\n    thomson_reuters_client=ThomsonReutersClient(),\n    au10tix_client=Au10tixClient(),\n    b2b_task_manager=B2BTaskManager(),\n    b2b_documents=PYBDocuments(),\n    task_execution_table=TaskExecutionTable(),\n    final_status_table=FinalStatusTable(ChangelogTable()),\n    tool_table=ToolTable(ChangelogTable()),\n    event_bridge_client=EventBridgeClient(),\n    retry_client=Retry(total=20),\n    payability_internal_api=PayabilityInternalAPI(),\n    entrust_client=EntrustClient(),\n    hubspot_client=HubSpotClient(),\n    supplier_event_logger=SupplierEventLogger()\n)\n----\n\n*Web API* handlers construct the clients they need per request. `execute_task.py` builds a full\n`TaskFactory` (including `entrust_client`, `hubspot_client` and `supplier_event_logger`, all\nneeded for the Entrust reprocess path);\n`get_task_result.py` and `delete_task_results.py` build partial factories — enough for the\nprocess steps they touch.\n\n[NOTE]\n====\nA partial `TaskFactory` will raise `KeyError` if it is asked to build a task whose dependencies\nwere not injected. When you add a client to a task, check every place that constructs a\n`TaskFactory`, not just `swf_app.py`.\n====\n\nThe worker's `Retry(total=20)` versus the API's default `Retry()` (`total=10`, `delay=10`) is\ndeliberate: vendor polling happens on the worker, where waiting is free.\n\n== Rules for new clients\n\n* One module (or package) per external system, under `components/`.\n* Read configuration from `config.config.settings` only — never `os.getenv`.\n* Reuse a `requests.Session` and set explicit timeouts; use `components/retry/` or an\n  `HTTPAdapter` retry for polling.\n* Raise on failure (`raise_for_status()`), and where the upstream body carries the reason,\n  surface it in the exception message — see the `_raise_with_body` helper in\n  `payability_internal_api.py`.\n* Inject the client through `TaskFactory` rather than constructing it inside a task.\n* Document it here and in xref:reference/settings.adoc[] if it needs new settings or secrets.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/au10tix.adoc","title":"Au10tix","description":"The Au10tix BOS4 identity-document verification client and its request template.","url":"https://docx.payability.com/automatic-uw-tool/components/au10tix.html","navOrder":20,"source":"= Au10tix\n:navtitle: Au10tix\n:description: The Au10tix BOS4 identity-document verification client and its request template.\n\nThin HTTP client for the *Au10tix BOS4* identity-document verification API, used only by the\nAu10tix handle task (xref:processes/validate-identity-document-au10tix.adoc[]).\n\n== `components/au10tix/client.py`\n\n[cols=\"1,3\"]\n|===\n| Base URL | `AU10TIX_API_URL` — `https://payability.au10tixservices.com/Au10tixBos4` in `{prod-env}`, `https://payability.au10tixservicesstaging.com/Au10tixBos4` in dev\n| Auth | `Authorization: JWT \\{AU10TIX_TOKEN}` (token from Secrets Manager: `prod/au10tix/token` or `test/au10tix/token`)\n|===\n\n[source,python]\n----\nclass Au10tixClient:\n\n    def __init__(self):\n        self.__token = settings.AU10TIX_TOKEN\n\n    def send_request(\n            self,\n            http_method: str,\n            url: str,\n            additional_headers: Optional[dict] = (),\n            payload: Optional[str] = None,\n            files: Optional[dict] = None,\n            query_params: Optional[dict] = None\n    ):\n        headers = {'Authorization': f'JWT {self.__token}'}\n        headers.update(additional_headers)\n\n        response = requests.request(\n            method=http_method,\n            url=url,\n            params=query_params,\n            data=payload,\n            files=files,\n            headers=headers,\n        )\n\n        response.raise_for_status()\n\n        return response.json()\n----\n\nThe caller passes the *full* URL (built from `settings.AU10TIX_API_URL`), so the client itself\nholds no path knowledge. Non-2xx raises `HTTPError`.\n\n[NOTE]\n====\nThis client uses module-level `requests.request` with no session reuse and no timeout — a slow\nsocket blocks the worker thread until the OS gives up. New clients must not copy that pattern;\nsee xref:contributing/python-standards.adoc[].\n====\n\n== Call sequence\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Step | Call\n\n| Submit\n| `POST \\{AU10TIX_API_URL}/IdentityDocuments/BeginProcessingRequest` — multipart with `requestData` (the JSON template) and `documentImage_Page0` (the image bytes). Returns `{\"RequestId\": …}`.\n\n| Poll\n| `POST \\{AU10TIX_API_URL}/IdentityDocuments/CheckReadinessOfResults` with `Content-Type: application/json` and `{\"idList\": [request_id]}`. Returns an array; element `0` is `true` when ready.\n\n| Fetch\n| `GET \\{AU10TIX_API_URL}/IdentityDocuments/Results/\\{request_id}` — accepted only when `CompletionStatus == 'Ok'`.\n|===\n\nBoth polling loops use the injected `Retry` client (10 attempts, 5 s apart) from the handle task.\n\n== `components/au10tix/request_template/`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Purpose\n\n| `process_identity_document.json` | The `BeginProcessingRequest` body, referencing the uploaded `documentImage_Page0` part.\n| `utils.py` | `read_request_template(name)` — reads the template bytes from this package.\n|===\n\n== Fields consumed from the response\n\nThe handle task reads:\n\n* `DocumentAuthenticity` (top level) — `Authentic` means authentic.\n* `ProcessingResult.IsDocumentExpired` — boolean.\n* `ProcessingResult.OverallQuality` — `Acceptable`.\n* `ProcessingResult.DocumentData2.FirstName.Value` / `.LastName.Value`, falling back to\n  `FullName.Value` split on spaces.\n\nSee xref:processes/validate-identity-document-au10tix.adoc[] for how those become verdicts.\n\nA mocked result for tests lives at\n`task/au10tix/handle_identity_document_validation_response/result.mock.json`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/entrust.adoc","title":"Entrust / Onfido","description":"The Entrust Identity Verification (Onfido) client: applicants, workflow runs, artifacts and step hydration.","url":"https://docx.payability.com/automatic-uw-tool/components/entrust.html","navOrder":21,"source":"= Entrust / Onfido\n:navtitle: Entrust / Onfido\n:description: The Entrust Identity Verification (Onfido) client: applicants, workflow runs, artifacts and step hydration.\n\n`components/entrust/client.py` wraps the Onfido REST API that backs Entrust Identity\nVerification. Consumed by xref:api/entrust.adoc#create-session[`POST /entrust/sessions`] and the\naftermath task (xref:processes/validate-identity-document-entrust.adoc[]).\n\ninclude::partial$entrust-coexistence.adoc[]\n\n== Configuration\n\n[cols=\"1,3\"]\n|===\n| Base URL | `ENTRUST_API_URL` — e.g. `https://api.us.onfido.com/v3.6`; it *already includes* the version segment, so paths are appended verbatim.\n| Workflow | `ENTRUST_WORKFLOW_ID` — the Onfido Studio workflow every run is created against.\n| Auth | `Authorization: Token token=\\{ENTRUST_TOKEN}` (Secrets Manager).\n| Webhook secret | `ENTRUST_WEBHOOK_TOKEN` — used by the webhook handler, not by this client.\n|===\n\n== Session HTTP behaviour\n\nThe client builds a `requests.Session` with a retrying adapter:\n\n[source,python]\n----\nretry = HTTPRetry(\n    total=5,\n    backoff_factor=0.5,\n    status_forcelist=(429, 500, 502, 503, 504),\n    respect_retry_after_header=True,\n    raise_on_status=False,\n)\n----\n\nOnly idempotent methods are retried (urllib3's default allowed set excludes `POST`), so creating\nan applicant or a workflow run is never silently duplicated. `respect_retry_after_header` honours\nOnfido's 429 backoff, and `raise_on_status=False` lets the client's own `raise_for_status()`\nsurface the final error unchanged. This matters because reconciling one completed run fans out\ninto many GETs — one per workflow step and one per downloaded file.\n\n== Methods\n\n=== Session creation\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Call\n\n| `create_applicant(first_name, last_name)`\n| `POST /applicants/` → the applicant object (at least `id`).\n\n| `create_workflow_run(applicant_id, custom_data=None)`\n| `POST /workflow_runs` with `workflow_id = ENTRUST_WORKFLOW_ID`; `custom_data` carries `{\"supplier_key\": …}` so a run can be correlated back. Returns `{id, sdk_token, link: {url, expires_at}, applicant_id, …}`.\n|===\n\n=== Reading a run\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Call\n\n| `get_workflow_run(workflow_run_id)`\n| `GET /workflow_runs/\\{id}` — the authoritative `status` (`approved`, `declined`, `review`, `abandoned`, `error`) and `output`. The webhook body carries only a minimal object, so this re-read is the source of truth.\n\n| `list_workflow_run_tasks(id)`\n| `GET /workflow_runs/\\{id}/tasks` — step summaries without `input`/`output`. Tolerates both a bare array and a `{\"tasks\": […]}` envelope.\n\n| `get_workflow_run_task(id, task_id)`\n| `GET /workflow_runs/\\{id}/tasks/\\{task_id}` — one step *with* its `input` and `output`.\n\n| `get_workflow_run_tasks(id)`\n| Convenience: list, then hydrate every step (skipping entries without an `id`). This is what makes the S3 archive contain the full decision trail — including the document check's `output.properties.date_of_expiry`.\n|===\n\n=== Captured artifacts\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Call\n\n| `list_documents(applicant_id)` / `download_document(id)`\n| `GET /documents?applicant_id=…` / `GET /documents/\\{id}/download` (raw bytes).\n\n| `list_live_photos(applicant_id)` / `download_live_photo(id)`\n| The selfie.\n\n| `list_live_videos(applicant_id)` / `download_live_video(id)`\n| Liveness video.\n\n| `list_motion_captures(applicant_id)` / `download_motion_capture(id)`\n| Onfido's alternative liveness modality — a run produces one or the other depending on the SDK flow.\n\n| `get_applicant_files(applicant_id)`\n| Downloads all documents *and* live photos as `EntrustFile`s, ready for multipart re-upload.\n\n| `get_applicant_liveness_files(applicant_id)`\n| Same for live videos and motion captures, kept separate so the aftermath task can upload liveness media in its own request after the documents.\n|===\n\n=== `EntrustFile`\n\n[source,python]\n----\n@dataclass\nclass EntrustFile:\n    filename: str\n    content_type: str\n    content: bytes\n    side: Optional[str] = None            # front / back — documents only\n    document_type: Optional[str] = None   # Onfido type, e.g. driving_licence\n    issuing_country: Optional[str] = None # ISO country, e.g. LKA\n    kind: str = KIND_DOCUMENT             # document | live_photo | live_video | motion_capture\n----\n\n`kind` lets callers decide which per-file metadata applies (an expiry attaches to documents and\nliveness media but not to the selfie). `content_type` is mapped from Onfido's `file_type`\n(`jpg`/`jpeg` → `image/jpeg`, `png`, `pdf`, `mov` → `video/quicktime`, `mp4`, `webm`), defaulting\nto `application/octet-stream`. A missing `file_name` becomes `\\{kind}_\\{id}.\\{file_type}`.\n\n== Error handling\n\nEvery call ends in `response.raise_for_status()`, so non-2xx raises `requests.HTTPError`.\nCallers translate that:\n\n* `POST /entrust/sessions` passes an Onfido 4xx through with the parsed upstream body under\n  `entrust_error`, and maps 5xx or connection errors to `502`.\n* The aftermath task lets download failures propagate so the SWF activity fails and retries;\n  step hydration is the one best-effort exception (the run summary is archived without `tasks`).\n\n== Tests\n\n`development/unit_tests/components_/entrust/test_client.py`.\n\nThe full vendor contract is available as an attachment:\nxref:reference/external-apis.adoc[Onfido Public API v3.6 spec].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/hubspot.adoc","title":"HubSpot","description":"The best-effort HubSpot CRM client that mirrors Entrust verification state onto a supplier's contact.","url":"https://docx.payability.com/automatic-uw-tool/components/hubspot.html","navOrder":22,"source":"= HubSpot\n:navtitle: HubSpot\n:description: The best-effort HubSpot CRM client that mirrors Entrust verification state onto a supplier's contact.\n\n`components/hubspot/client.py` mirrors a supplier's Entrust verification lifecycle onto their\nHubSpot contact, so Growth & Retention can see in the CRM whether a supplier has started,\npassed, been denied, or is under review for ID verification.\n\n== Contract\n\n[cols=\"1,3\"]\n|===\n| Auth | HubSpot Private App token — `settings.HUBSPOT_PRIVATE_APP_TOKEN` (Secrets Manager).\n| Matching property | `supplierkey` on the contact — searched with a CRM `EQ` filter, `limit 1`.\n| Written property | `entrust_verification_status`.\n|===\n\n[source,python]\n----\nclass EntrustVerificationStatus:\n    IN_PROGRESS = 'in_progress'\n    VERIFIED = 'verified'\n    DENIED = 'denied'\n    IN_REVIEW = 'in_review'\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Value | Set when\n\n| `in_progress` | `POST /entrust/sessions` created (or reused nothing and minted) a session.\n| `verified` | The workflow run resolved to `approved`.\n| `in_review` | The run is held for manual review.\n| `denied` | Any other terminal status — declined, abandoned, error.\n|===\n\n== `update_entrust_verification_status(supplier_key, status, logger=None)`\n\nFinds the contact whose `supplierkey` equals `supplier_key` and PATCHes its\n`entrust_verification_status`. Returns `True` when a contact was found and updated, `False`\notherwise.\n\n*Best effort by design.* HubSpot is a downstream, non-authoritative sync target, so:\n\n* a missing contact logs a warning and returns `False`;\n* any API error is logged, reported to Sentry via `sentry_sdk.capture_exception()`, and returns\n  `False`;\n* the method never raises, so it can never fail session creation or the SWF aftermath task.\n\nMultiple matches are unexpected (the key is meant to be unique) — the first is taken.\n\nAn optional correlation-aware `logger` can be passed so the lines join the rest of the request\nor task log; otherwise a module logger is used.\n\n== Why a local client instead of `pyb-hubspot`\n\nThe shared `pyb-hubspot` library requires Python >= 3.12, while this service is pinned to Python\n{python-version} (its `pyb-sdk` dependency supports only 3.9). Rather than import a 3.12-only\npackage, this client builds directly on `hubspot-api-client` (Python >= 3.7, no conflicting\ndependencies) and reproduces just the two calls needed: a CRM contact search by property, and a\npartial property PATCH.\n\n== Callers\n\n* `interface/web_api/endpoint_handling/create_entrust_session.py` — `in_progress` on create.\n* `task/entrust/handle_identity_document_validation_response/implementation.py` —\n  `verified` / `in_review` / `denied` on completion, via `__sync_external_status`.\n\nBoth receive the client through construction (`swf_app.py` injects it into `TaskFactory`; the\nendpoint handler builds its own). Both call sites emit a matching supplier event through\nxref:components/event-logger.adoc[`SupplierEventLogger`] at the same moment, with the same status\nvalue — in the aftermath task the two are driven together by `__sync_external_status`.\n\n== Tests\n\n`development/unit_tests/components_/test_hubspot_client.py` and\n`development/unit_tests/interface_/web_api/endpoint_handling/test_create_entrust_session_hubspot.py`.\n\n== Operational notes\n\n* The two custom contact properties (`supplierkey`, `entrust_verification_status`) must exist in\n  the HubSpot portal; a typo in either name silently degrades to \"no contact found\" or a PATCH\n  error, both of which only appear in logs and Sentry.\n* Because failures are swallowed, HubSpot drift is *not* visible in the underwriting data. If\n  the CRM looks stale, check Sentry for `HubSpotClient` errors rather than the task status.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/event-logger.adoc","title":"Supplier event logger","description":"The best-effort emitter of supplier-scoped events to Payability's internal EventLogger service.","url":"https://docx.payability.com/automatic-uw-tool/components/event-logger.html","navOrder":23,"source":"= Supplier event logger\n:navtitle: Supplier event logger\n:description: The best-effort emitter of supplier-scoped events to Payability's internal EventLogger service.\n\n`components/event_logger/client.py` emits *supplier events* to Payability's internal\n**EventLogger** service, so a supplier's Entrust identity-verification lifecycle is visible in\nthe platform event stream that Growth & Retention, Compliance and analytics consume.\n\n[NOTE]\n====\nThis is a different channel from xref:events/eventbridge-events.adoc[EventBridge]. EventBridge\ncarries machine-to-machine process notifications between services; EventLogger records\nsupplier-scoped, human-readable events against a supplier timeline. The Entrust flow writes to\nboth, plus xref:components/hubspot.adoc[HubSpot].\n====\n\n== Contract\n\nBuilt on the internal `eventlogger` SDK (`EventLoggerClient.captureMessage`), which posts to\n`<dsn>/capture-message`.\n\n[cols=\"1,3\"]\n|===\n| Endpoint | `EVENT_LOGGER_URL` — `https://api.payability.com/eventlogger/` in `{prod-env}`, an API Gateway stage URL in dev\n| Instance | `EVENT_LOGGER_INSTANCE_ID` (`8001`)\n| Auth | `EVENT_LOGGER_KEY` (Secrets Manager)\n| Target | `TargetTypes.SUPPLIER`, targeted by `supplier_key`\n| Message type | `EventTypes.EVENT`\n|===\n\n== `send_entrust_status_event(...)`\n\n[source,python]\n----\ndef send_entrust_status_event(\n        self,\n        supplier_key: str,\n        status: str,\n        workflow_run_id: str = None,\n        person_id: str = None,\n        verdict: str = None,\n        logger: logging.Logger = None\n) -> bool\n----\n\nSends `\"Entrust identity verification: \\{status}\"` with the correlating ids in `extra`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `extra` key | Value\n\n| `status` | The lifecycle status — the same `EntrustVerificationStatus` values HubSpot receives: `in_progress`, `verified`, `in_review`, `denied`.\n| `tool` | Always `ENTRUST`, so consumers can distinguish this from other identity-verification sources.\n| `verdict` | `VERIFIED` / `NOT_VERIFIED`, when a terminal verdict exists (omitted for `in_progress` and `review`).\n| `workflow_run_id` | The Onfido run id, when known.\n| `person_id` | The supplier owner, when known.\n|===\n\nStructured data goes in `extra` because the SDK's `context` kwarg is not persisted by its\n`build_msg`.\n\n== Lazy, guarded construction\n\nThe client is instantiated eagerly by the endpoint handler and the worker, but the underlying\nSDK client is built on first use and its outcome cached:\n\n* Missing `EVENT_LOGGER_URL`, `EVENT_LOGGER_INSTANCE_ID` or `EVENT_LOGGER_KEY` → a warning is\n  logged once and emission becomes a permanent no-op for that process. Leaving the URL empty is\n  therefore a supported way to disable emission in an environment.\n* A construction failure (the SDK asserts on bad configuration) is logged, reported to Sentry,\n  and cached as failed so it is not retried on every event.\n\n== Best effort by design\n\nLike xref:components/hubspot.adoc[HubSpot], EventLogger is a downstream, non-authoritative sink.\nEvery send is wrapped: failures are logged, captured to Sentry, and returned as `False`. The\nmethod never raises, so it can never fail Entrust session creation or the SWF aftermath task.\n\n[WARNING]\n====\nBecause failures are swallowed, a missing supplier event is invisible in the underwriting data.\nIf the event stream looks incomplete, check Sentry for `SupplierEventLogger` errors and verify\nthe three `EVENT_LOGGER_*` values in the environment.\n====\n\n== Callers\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Caller | Emits\n\n| `interface/web_api/endpoint_handling/create_entrust_session.py`\n| `in_progress` right after the session is persisted, alongside the HubSpot sync.\n\n| `task/entrust/handle_identity_document_validation_response/implementation.py`\n| `verified` / `in_review` / `denied` from `__sync_external_status`, which drives HubSpot and EventLogger together. The terminal path also passes `verdict`.\n|===\n\nThe worker injects it into `TaskFactory` as `supplier_event_logger`\n(`swf_app.py`); `execute_task.py` builds one too, so the Entrust reprocess path can construct\nthe task.\n\n== Dependency\n\nThe `eventlogger` package is a private git dependency\n(`https://github.com/Payability/EventLoggerPySDK.git`, see `[tool.uv.sources]` in\n`pyproject.toml`), so installing requires git access — the same `GIT_TOKEN` as `pyb-sdk`.\n\n== Tests\n\n`development/unit_tests/components_/test_supplier_event_logger.py`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/thomson-reuters.adoc","title":"Thomson Reuters CLEAR","description":"The CLEAR S2S client — XML over mutual TLS — and the request templates.","url":"https://docx.payability.com/automatic-uw-tool/components/thomson-reuters.html","navOrder":24,"source":"= Thomson Reuters CLEAR\n:navtitle: Thomson Reuters CLEAR\n:description: The CLEAR S2S client — XML over mutual TLS — and the request templates.\n\n`components/thomson_reuters/client.py` wraps the *Thomson Reuters CLEAR S2S API*: XML over\nHTTPS with mutual TLS. It backs three processes — KYC, personal background report and business\nbackground report.\n\n== Contract\n\n[cols=\"1,3\"]\n|===\n| Base URL | `THOMSON_REUTERS_API_URL` — `https://s2s.thomsonreuters.com/api` in `{prod-env}`, `https://s2s.beta.thomsonreuters.com/api` in dev\n| Auth | HTTP Basic (`THOMSON_REUTERS_USER` / `THOMSON_REUTERS_PASSWORD`) *plus* a client certificate\n| Client certificate | PEM cert/key at `THOMSON_REUTERS_CERT_FILE_PATH` / `THOMSON_REUTERS_KEY_FILE_PATH`\n| Payload | `Content-type: application/xml`; responses parsed with `xmltodict.parse()`\n|===\n\n[source,python]\n----\nclass ThomsonReutersClient:\n\n    def __init__(self):\n        current_directory = os.path.dirname(__file__)\n        cert_absolute_path = os.path.join(current_directory, '..', '..', settings.THOMSON_REUTERS_CERT_FILE_PATH)\n        key_absolute_path = os.path.join(current_directory, '..', '..', settings.THOMSON_REUTERS_KEY_FILE_PATH)\n\n        self.__cert = (cert_absolute_path, key_absolute_path)\n        self.__user = settings.THOMSON_REUTERS_USER\n        self.__password = settings.THOMSON_REUTERS_PASSWORD\n\n    def send_request(self, http_method, url, payload=None, query_params=None):\n        response = requests.request(\n            method=http_method,\n            url=url,\n            params=query_params,\n            data=payload,\n            auth=HTTPBasicAuth(self.__user, self.__password),\n            headers={'Content-type': 'application/xml'},\n            verify=False,\n            cert=self.__cert\n        )\n\n        response.raise_for_status()\n\n        return xmltodict.parse(response.text)\n----\n\n[IMPORTANT]\n====\nThe paths are resolved relative to the module, so the cert and key must exist on disk before the\nclient is constructed. Where they come from per environment (a PFX bundle in production,\nready-made PEMs in dev) is described in xref:operations/configuration.adoc[] and\nxref:operations/deployment.adoc[].\n\n`verify=False` disables *server* certificate verification while still presenting a *client*\ncertificate. It suppresses hostname/CA validation against CLEAR — worth knowing about, and worth\nnot copying into new clients.\n====\n\n== Request templates\n\n`components/thomson_reuters/request_template/` holds one XML template per CLEAR operation, filled\nby `populate_request_template(template_file_name, **kwargs)`. Every template carries a\n*PermissiblePurpose* block built from settings — `THOMSON_REUTERS_PERMISSIBLE_PURPOSE_GLB` (`B`),\n`_DPPA` (`3`), `_VOTER` (`5`) — which is the legal basis under which CLEAR data may be pulled.\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Template | CLEAR operation | Used by\n\n| `clear_id_confirm_person_search.xml`\n| EIDV person search\n| xref:processes/validate-personal-information.adoc[]\n\n| `background_person_search.xml`\n| Person search v3\n| xref:processes/download-personal-background-report.adoc[] (locate the person)\n\n| `background_person_report.xml`\n| Person report\n| personal background report (the report itself)\n\n| `background_business_search.xml`\n| Business search\n| xref:processes/download-business-background-report.adoc[] (locate the business)\n\n| `background_business_report.xml`\n| Business report\n| business background report\n|===\n\n== Endpoints called\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Purpose | Request\n\n| EIDV search | `POST /v2/eidvperson/searchResults`, then `GET` the returned `Uri`\n| Person search | `POST /v3/person/searchResults`, then `GET` the returned `Uri`\n| Person report | `POST /v2/personReport/reportResults`, then poll `GET \\{Uri}` while `Status.StatusCode == 204`\n| Business search | `POST /v2/business/searchResults`, then `GET` the returned `Uri`\n| Business report | `POST /v2/businessReport/reportResults`, then poll `GET \\{Uri}` while `Status.StatusCode == 204`\n|===\n\n== Namespace handling\n\nCLEAR responses use namespace prefixes (`ns2:`, `ns3:`, `ns10:` …) that are *not* stable across\noperations or versions. Two approaches coexist in the codebase:\n\n* `utils/task_utils.py` → `find_key_in_tr_response(result, key_name, prefix='ns')` regex-searches\n  the stringified response for `ns<digits>:<KeyName>` and raises `MalformedResponse` when absent.\n  Used by the KYC and personal-report tasks — this is the pattern to follow.\n* The business-report task addresses `ns2:`/`ns3:`/`ns10:`/`ns11:` literally, with a fallback for\n  the report-details node. A prefix shift there raises `KeyError` instead of a clean\n  `MalformedResponse`.\n\n== Failure semantics\n\n* Non-2xx → `HTTPError`. The KYC task inspects the error body for a CLEAR `Status.Message`,\n  emits `ThomsonReutersNotWorkingEvent`, and then fails the task.\n* `GroupCount == 0` or a missing count → `ResultNotFound`.\n* A missing `Uri`, `GroupId` or expected node → `MalformedResponse`.\n* A report that never leaves `204` exhausts the `Retry` client → `RetryException`.\n* A personal report missing any of the seven required sections → `MissingResponseSection`.\n\n== Tests\n\n* Unit: `development/unit_tests/task_/thomson_reuters/**`.\n* Integration (hits the CLEAR sandbox — run manually, never in CI):\n  `development/integration_tests/components_/thomson_reuters/test_client.py`.\n* A recorded personal-report response used by test mode and tests:\n  `examples/thomson_reuters_personal_background_check_response.json`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/aws.adoc","title":"AWS clients","description":"The DynamoDB, S3, SWF and EventBridge wrappers, plus the purge helpers.","url":"https://docx.payability.com/automatic-uw-tool/components/aws.html","navOrder":25,"source":"= AWS clients\n:navtitle: AWS clients\n:description: The DynamoDB, S3, SWF and EventBridge wrappers, plus the purge helpers.\n\nAll AWS access goes through these wrappers. Table shapes are in xref:data/dynamodb.adoc[]; S3\nkey conventions in xref:data/s3-datalake.adoc[].\n\n== DynamoDB\n\nEvery client uses `boto3.resource('dynamodb')` and a table name from settings.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| File | Table setting | Role\n\n| `components/task_execution_table.py`\n| `SUPPLIER_DATA_VALIDATION_TASK_TABLE_NAME`\n| Task lifecycle. `save`, `get`, `update`, `get_by_supplier_key_and_process_name` (GSI, paginated), `get_latest_processed_task`, `get_latest_task`. Status constants `PENDING_STATUS`, `PROCESSED_STATUS`, `FAILED_STATUS`, `PURGED_STATUS`; `update` rejects anything else.\n\n| `components/aws/dynamodb/final_status_table.py`\n| `FINAL_STATUS_TABLE_NAME`\n| Verdicts per `correlation_id`. `save`, `update`, `get_by_correlation_id`, `get_latest_by_correlation_id`; writes a changelog row on every mutation; paginates queries correctly.\n\n| `components/aws/dynamodb/tool/tool_table.py`\n| `TOOL_TABLE_NAME`\n| Per-supplier/person tool state. `save`, `update` (upsert), `get_by_supplier_and_type`, `get_all_by_supplier`, `get_tools_and_create_missing`, `create_missing_tools`. Lookups use paginated `scan` with filters.\n\n| `components/aws/dynamodb/changelog_table.py`\n| `CHANGELOG_TABLE_NAME`\n| Append-only audit. `save(supplier_key, changelog_type, data, changelog_action)` with enums `ChangelogType` (`FINAL_STATUS`, `TOOL`) and `ChangelogAction` (`INIT`, `UPDATE`); stamps `app_version` from `version.py`.\n|===\n\n`FinalStatusTable` and `ToolTable` both take a `ChangelogTable` in their constructor — that is\nhow auditing is guaranteed rather than remembered.\n\n=== Enums and exceptions\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Module | Contents\n\n| `aws/dynamodb/tool/tool_status.py`\n| `ToolStatus`: `NOT_RUN`, `PENDING`, `ERROR`, `NOT_VERIFIED`, `VERIFIED`, `VERIFIED_ACCESS`, `VERIFIED_ADVANCE`, `VERIFIED_ACCESS_AND_ADVANCE`.\n\n| `aws/dynamodb/tool/tool_type.py`\n| `ToolType` (storage key → display name) plus `get_by_process_name()`.\n\n| `aws/dynamodb/exceptions.py`\n| `NotFoundFinalStatus`, `NotFoundTool`, `NotSupportedToolType`.\n|===\n\n[source,python]\n----\nclass ToolType(Enum):\n    THOMSON_REUTERS_CLEAR_ID = 'Thomson Reuters Clear ID (KYC Check)'\n    THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT = 'Thomson Reuters Personal Background Report'\n    AU10TIX = 'Au10tix'\n    ENTRUST = 'Entrust Identity Verification'\n\n    @staticmethod\n    def get_by_process_name(process_name: str) -> 'ToolType':\n        supported_processes = {\n            Process.VALIDATE_IDENTITY_DOCUMENT: ToolType.AU10TIX,\n            Process.VALIDATE_PERSONAL_INFORMATION: ToolType.THOMSON_REUTERS_CLEAR_ID,\n            Process.DOWNLOAD_PERSONAL_BACKGROUND_REPORT: ToolType.THOMSON_REUTERS_PERSONAL_BACKGROUND_REPORT\n        }\n\n        try:\n            return supported_processes[process_name]\n        except KeyError:\n            raise NotSupportedToolType(process_name=process_name)\n----\n\ninclude::partial$entrust-coexistence.adoc[]\n\n`ToolTable.SUPPORTED_TOOLS` *does* include `ENTRUST`, so `GET /tools` auto-creates an Entrust row\n(status `NOT_RUN`) for every supplier/person it is asked about. Only the process → tool *mapping*\nstill points at Au10tix.\n\n== S3\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Contents\n\n| `components/s3_client.py`\n| `upload_json_data(data, bucket, key)` — JSON upload with `ACL: bucket-owner-full-control`; `get_json_data(bucket, path)` — download and parse.\n\n| `components/s3_purgable/purgable_s3_object_producer.py`\n| `PurgableS3ObjectProducer` interface: `purge(supplier_key, person_id, s3_object_purger) -> List[str]`.\n\n| `components/s3_purgable/s3_object_purger.py`\n| `S3ObjectPurger.delete_s3_object(bucket_name, object_key)`.\n|===\n\nImplemented by the three Thomson Reuters handle tasks and driven by\nxref:api/tasks.adoc#delete-task-results[`DELETE /tasks/result`].\n\n== SWF (worker side)\n\n`components/swf_client.py` — a `boto3.client('swf')` activity worker.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `poll_activity_task(domain, identity, task_list)`\n| Loops on `poll_for_activity_task` until a `taskToken` appears; swallows `ReadTimeoutError` and `ConnectionClosedError` (long-poll timeouts are normal).\n\n| `notify_about_task_success(task_token, result)`\n| `respond_activity_task_completed` with the JSON-encoded output.\n\n| `notify_about_task_failure(task_token, reason)`\n| `respond_activity_task_failed`. An `Exception` becomes `{error_type, error_message}`; a short human-readable `reason` (≤ 256 chars) is derived and the full payload goes into `details` (≤ 32768 chars). Exceeding SWF's caps would make the call itself fail and leave the activity hanging until its timeout.\n|===\n\n== EventBridge\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Contents\n\n| `event_bridge/event_bridge_event.py` | Base `EventBridgeEvent` holding the `put_events` entry; `get_body()`.\n| `event_bridge/event_bridge_client.py` | `put_event(event)`; raises `ConnectionError` on a non-zero `FailedEntryCount`.\n| `event_bridge/events/` | `ProcessFinishedEvent`, `ThomsonReutersDataMatchEvent`, `ThomsonReutersDataNotMatchEvent`, `ThomsonReutersNotWorkingEvent`.\n|===\n\nCatalog and payloads: xref:events/eventbridge-events.adoc[].\n\n== CloudWatch\n\nNot a `components/` client: `utils/metric_utils.py` wraps `pyb-sdk`'s\n`CriticalMetricNamespace`. See xref:operations/observability.adoc[].\n\n== IAM surface\n\nThe two ECS task roles need, at minimum: DynamoDB read/write on the four tables and the\n`supplier_key-process_name-index` GSI; `s3:PutObject`/`GetObject`/`DeleteObject` on the datalake\nbucket; SWF poll and respond on the domain and task list (worker); `events:PutEvents` on the\nbus; and CloudWatch `PutMetricData`. Secrets Manager access is only needed at *build* time by\n`config_setup.py`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/payability-internal.adoc","title":"Payability internal services","description":"The internal REST API, the document service, and the B2B Task Manager.","url":"https://docx.payability.com/automatic-uw-tool/components/payability-internal.html","navOrder":26,"source":"= Payability internal services\n:navtitle: Payability internal services\n:description: The internal REST API, the document service, and the B2B Task Manager.\n\nThree clients talk to Payability's own platform. All three authenticate with `PybApiAuth`\n(`pyb-sdk`), which exchanges the Basic `PAYABILITY_AUTH_TOKEN` for an `X-Session-Token` and\nattaches it to every request — no separate credential is needed.\n\n== `components/payability_internal_api.py`\n\nBase URL `PAYABILITY_INTERNAL_API_URL` (`https://api.payability.com/v1/mp` in `{prod-env}`,\n`https://<env>.dev.payability.com/database_v2` in dev).\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Call\n\n| `get_supplier_info(supplier_key, detailed=False)`\n| `GET /internal/suppliers/\\{supplier_key}` (`?detailed=1`). `detailed` includes `owners`, each with `loginKey`, `firstName`, `lastName` — the source for name matching and Onfido applicant creation.\n\n| `get_user_perms(login_key)`\n| `GET /internal/logins/\\{login_key}/perms`. Wrapped in `@lru_cache(1)`, so within one process only the most recent `login_key` is cached.\n\n| `upload_supplier_documents(supplier_key, files, document_type, compliance_key, description=None, sides=None, expiration_dates=None, file_document_types=None, file_issuing_countries=None)`\n| `POST /sup_widget/suppliers/\\{supplier_key}/documents` as `multipart/form-data` with a repeatable `files` part. Returns the created document record(s), each with an `id`.\n\n| `list_supplier_documents(supplier_key, document_type)`\n| `GET /internal/suppliers/\\{supplier_key}/documents/\\{document_type}`. Each record carries an `id` and a nested `compliance` object whose `status` (`pending`, `approved`, `archived`) drives the archive decision.\n\n| `update_supplier_document_statuses(supplier_key, document_ids, status)`\n| `PATCH /internal/suppliers/\\{supplier_key}/documents` with `[{\"id\": …, \"status\": …}, …]`. Used to archive stale captures and to approve freshly uploaded ones; the internal API cascades the \"Upload ID\" compliance status once all identity documents are approved.\n|===\n\n=== Per-file metadata on upload\n\n`document_type` and `compliance_key` are single values applied to the whole call.\n`sides`, `expiration_dates`, `file_document_types` and `file_issuing_countries` are *positional*\narrays emitted as repeated form fields — one entry per `files` part, in order, empty string where\na file has no value — so the server can align them with the uploaded parts. The form field names\ndiffer slightly from the Python argument names:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Argument | Form field\n\n| `sides` | `side`\n| `expiration_dates` | `expiration_date`\n| `file_document_types` | `document_types`\n| `file_issuing_countries` | `issuing_country`\n|===\n\nThe `data` payload is built as an ordered list of tuples rather than a dict, precisely so those\nkeys can repeat.\n\n=== Error bodies are preserved\n\nThe three document methods go through `_raise_with_body`, which raises `requests.HTTPError` with\na truncated copy of the upstream body in the message:\n\n[source,python]\n----\ndef _raise_with_body(response: requests.Response):\n    if response.ok:\n        return\n\n    body = (response.text or '')[:1000]\n    raise requests.HTTPError(\n        '{status} {reason} for url: {url} - response body: {body}'.format(...),\n        response=response\n    )\n----\n\nA bare `raise_for_status()` would discard the rejection reason (an invalid `compliance_key`, an\nunaccepted file, an unknown document id), which is what makes 4xx responses debuggable in the\nworker logs and in the SWF failure details.\n\n`get_supplier_info` and `get_user_perms` still use plain `raise_for_status()`.\n\n== `components/pyb_documents.py`\n\nFetches previously uploaded identity-document images from the *PYB Documents* service\n(`PYB_DOCUMENTS_URL`).\n\n[source,python]\n----\ndef get_document(self, document_identifier: str) -> bytes:\n    response = self.__session.get(\n        url='{}/documents/{}'.format(settings.PYB_DOCUMENTS_URL, document_identifier)\n    )\n\n    if response.status_code == 404:\n        raise DocumentNotFound(document_identifier)\n\n    response.raise_for_status()\n\n    return response.content\n----\n\nUsed by the Au10tix handle task to obtain the image it submits for verification.\n\n== `components/b2b_task_manager.py`\n\nStarts SWF workflows through the shared *B2B Task Manager* HTTP service\n(`B2B_TASK_MANAGER_URL`) — the Web API never calls SWF directly.\n\n* `start_swf(domain, workflow_type, workflow_type_version, workflow_identifier, input_body)` →\n  `POST /executeWorkflow`, injecting `gci` from `B2B_TASK_MANAGER_GCI` and `taskList=default`.\n* A 2xx response carrying `errorMessages` or `exceptionMessages` raises `CannotRunWorkflow`,\n  which renders `WorkflowExecutionAlreadyStarted` as `\"Task already running\"`. Because it is a\n  `TaskExecutionError`, `task_failure_handling` re-raises it untouched and the API answers `400`\n  (or `409` for the Entrust reprocess path).\n\nFull detail: xref:events/swf-orchestration.adoc[].\n\n== Who uses what\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Consumer | Internal calls\n\n| Au10tix handle task\n| `PYBDocuments.get_document`, `get_supplier_info(detailed=True)` for name matching.\n\n| Entrust session endpoint\n| `get_supplier_info(detailed=True)` for the owner's name.\n\n| Entrust aftermath task\n| `list_supplier_documents`, `update_supplier_document_statuses` (archive, then approve), `upload_supplier_documents` (twice: documents then liveness media).\n\n| `Permissions`\n| `get_user_perms`.\n\n| Every request task, the Entrust webhook, the Entrust reprocess path\n| `B2BTaskManager.start_swf`.\n|===\n\nThe document upload route expects the `PermCode.INT_LOGIN` carried by the session token that\n`PybApiAuth` obtains, which is why no extra secret is involved.\n\n== Tests\n\n`development/unit_tests/components_/test_payability_internal_api.py`,\n`development/unit_tests/components_/test_swf_client.py`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/auth-permissions.adoc","title":"Auth & permissions","description":"Token validation, process sources, permission codes, and webhook signature trust.","url":"https://docx.payability.com/automatic-uw-tool/components/auth-permissions.html","navOrder":27,"source":"= Auth & permissions\n:navtitle: Auth & permissions\n:description: Token validation, process sources, permission codes, and webhook signature trust.\n\nThree mechanisms establish trust: a session token for humans and services, a process *source*\nthat says where a request came from, and an HMAC signature for the vendor webhook.\n\n== `components/auth/payability_auth_service.py`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Call\n\n| `validate_auth_token(auth_token)` | `GET \\{PAYABILITY_INTERNAL_API_URL}/internal/token/validate` with `X-Session-Token`; returns the auth payload (`login_id`, `source`, optional `supplier_key`).\n| `generate_auth_token()` | `GET /internal/auth/login` with the Basic `PAYABILITY_AUTH_TOKEN`; returns a `token`. (Service login; not used on the request path.)\n|===\n\n== `components/auth/jwt_authorizer.py`\n\nThe FastAPI dependency guarding every authenticated route.\n\n[source,python]\n----\nclass JWTAuthorizer:\n    def __init__(self, auth_token: str = Security(APIKeyHeader(name='x-session-token', auto_error=False))):\n        self._auth_service = PayabilityAuthService()\n        self._auth_token = auth_token\n\n        self.auth_details = {}\n        self.data = self.verify_jwt()\n----\n\nFlow: validate the token against the auth API → resolve `ProcessSource` from the `source` claim →\ndecode the JWT with `verify_signature: False` to read `login_key` → return an\n`AuthValidateResponse`.\n\nAccessors: `get_source()`, `get_login_key()`, `get_login_id()`, `get_supplier_key()`.\n\nError mapping:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Status | Cause\n\n| `400` | No `source` in the auth response.\n| `401`, `404` (and `400`) | Passed through from the auth API.\n| `409` | A `KeyError` reading the auth payload.\n| `500` | Anything else, including auth-API 5xx and unexpected errors.\n|===\n\n[NOTE]\n====\nThe JWT signature is deliberately not verified here — authenticity comes from the auth API round\ntrip; the local decode only extracts `login_key`. The same decode helper exists on\n`BaseEndpointHandler.decode_jwt`.\n====\n\n== `components/process_source.py`\n\n[source,python]\n----\nclass ProcessSource(Enum):\n    SUPPLIER_DASHBOARD = 'supplier_dashboard'\n    INTERNAL = 'internal'\n    ONBOARDING = 'onboarding'\n----\n\nThe source is carried on the token, persisted on `TaskExecutionTable` rows, and drives two\nbusiness rules:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Source | Treatment\n\n| `internal`\n| Staff calls. Permission-checked. May use `force_new_execution`, read results, purge results, override final statuses, and create Entrust sessions for anyone. Unlimited Entrust sessions.\n\n| `supplier_dashboard`\n| A supplier acting on themselves. Skips permission checks but is scoped to its own `supplier_key`; capped at 3 process executions per supplier + process and 3 Entrust sessions per person; blocked from the internal-only endpoints.\n\n| `onboarding`\n| Self-service origin used by the supplier onboarding flow, added for Entrust. No permissions required, must name the target supplier and owner, and has its own 3-sessions-per-person budget so onboarding cannot exhaust a supplier's later attempts.\n|===\n\n== `components/permissions/permissions.py`\n\n[source,python]\n----\nclass Permissions:\n    class PermCode:\n        DEV = 'DEV'\n        FINANCE = 'FINANCE'\n        REST_UW_ACCESS = 'REST_UW_ACCESS'\n        UNDERWRITING = 'UNDERWRITING'\n        INT_SUPER_USER = 'INT_SUPER_USER'\n\n    def check_permissions(self, login_key: str, perm_codes: List[str]) -> bool:\n        user_perms = self.payability_internal_api.get_user_perms(login_key=login_key)\n\n        return any(user_perm['permCode'] in perm_codes for user_perm in user_perms)\n----\n\n`BaseEndpointHandler.check_permissions(login_key, perm_codes, process_source)` wraps it:\n\n* returns immediately for `SUPPLIER_DASHBOARD`;\n* appends `INT_SUPER_USER` to the requested codes, so a super user always passes;\n* an exception from the auth API sets a `500` response and raises `StopExecution`;\n* no matching perm sets a `403` response and raises `StopExecution`.\n\nWhich endpoints require which codes:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Codes\n\n| `POST /tasks/execute` | `DEV`, `FINANCE` (+ `INT_SUPER_USER`)\n| `POST /entrust/sessions` (internal source only) | `DEV`, `FINANCE` (+ `INT_SUPER_USER`)\n| `GET /tasks/result?detailed=true` | `DEV`, `FINANCE`, `REST_UW_ACCESS` (+ `INT_SUPER_USER`); without them CLEAR ID payloads are masked and other processes are refused\n| `GET /tools`, `GET /tasks/\\{id}`, `DELETE /tasks/result`, `PUT …/final_statuses` | no perm check — gated by source instead\n|===\n\n`UNDERWRITING` is defined but not currently used by any endpoint.\n\n== Webhook signature\n\n`POST /entrust/webhook` cannot present a Payability token, so it lives on a router *without* the\n`JWTAuthorizer` dependency and verifies an HMAC instead:\n\n[source,python]\n----\nexpected_signature = hmac.new(\n    key=webhook_token.encode('utf-8'),\n    msg=self.__raw_body,\n    digestmod=hashlib.sha256\n).hexdigest()\n\nreturn hmac.compare_digest(expected_signature, self.__signature)\n----\n\nThe secret is `ENTRUST_WEBHOOK_TOKEN`. An unset secret is treated as a verification failure (and\nlogged as a configuration error), never as a bypass. The HMAC must be computed over the exact\nreceived bytes, which is why the route reads the raw body. See xref:api/entrust.adoc#webhook[].\n\n== `StopExecution`\n\n`interface/web_api/endpoint_handling/base.py` defines `StopExecution`, a control-flow exception:\na handler sets `self.response` and raises it, and `handle()` catches it to return that response.\nIt is not an error type — it is how the handlers implement \"answer now, skip the rest\", including\nthe dedup short-circuit in the Entrust session endpoint.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"components/utilities.adoc","title":"Utilities","description":"Retry, logging, metrics and the small helpers under utils/.","url":"https://docx.payability.com/automatic-uw-tool/components/utilities.html","navOrder":28,"source":"= Utilities\n:navtitle: Utilities\n:description: Retry, logging, metrics and the small helpers under utils/.\n\n== `components/retry/`\n\n[source,python]\n----\nclass Retry:\n    def __init__(self, total: int = 10, delay: int = 10): ...\n\n    def execute(self, method, method_args: dict, total=None, delay=None,\n                retry_condition=None, exception_force_list=()): ...\n----\n\nGeneric poll-and-retry. `execute` calls `method(**method_args)` up to `total` times, sleeping\n`delay` seconds between attempts, and returns the first result that satisfies the stopping\ncondition:\n\n* `retry_condition(result)` truthy → retry;\n* no `retry_condition` and a falsy result → retry;\n* an exception in `exception_force_list` → retry.\n\nExhausting the attempts raises `RetryException` (a `ConnectionError`,\n`components/retry/exceptions.py`).\n\nInstances: the worker injects `Retry(total=20)`; the Web API's factories use the default\n`Retry()`. Call sites override per loop — the Au10tix readiness and result polls use\n`total=10, delay=5`.\n\n[NOTE]\n====\n`Retry` sleeps on the calling thread, which is fine for a worker thread but would block a web\nrequest. Keep it out of the request path.\n====\n\n== `components/logger.py`\n\n[source,python]\n----\ndef get_logger(name: str, correlation_id: str) -> logging.Logger:\n    log = logging.getLogger(name)\n    log.setLevel(level=logging.DEBUG)\n    formatter = logging.Formatter(f'{correlation_id} | %(name)s | %(levelname)s | %(asctime)s - %(message)s')\n\n    if not log.handlers:\n        ch = logging.StreamHandler()\n        ch.setLevel(level=logging.INFO)\n        log.addHandler(ch)\n\n    for handler in log.handlers:\n        handler.setFormatter(formatter)\n\n    return log\n----\n\nEvery line is prefixed with the correlation id, so one execution can be grepped out of the\nworker's interleaved thread output. Safe to call repeatedly for the same `name`: the handler is\nattached once and only the formatter is refreshed, so handlers never accumulate and lines are\nnever duplicated.\n\n`TaskFactory` creates one logger per task (`name = task_name`,\n`correlation_id = task_correlation_id`); the Entrust endpoint handlers use `entrust/create_session`\nand `entrust/webhook`, the latter re-created with the `workflow_run_id` once the body is parsed.\n\n== `interface/web_api/web_request_logger.py`\n\nLogs one INFO line per handled request:\n`[\\{login_id}:\\{source}] \\{path} (\\{payload})` under the logger name `WEB REQUEST LOGGER`.\nCalled at the top of every handler, so request bodies — including PII in\n`validate_personal_information` inputs — appear in the Web API log stream.\n\n== `utils/`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Module | Contents\n\n| `task_utils.py`\n| `task_failure_handling(task_instance)` — the failure context manager (xref:domain-model.adoc#failure-handling[]). `find_key_in_tr_response(result, key_name, prefix='ns')` — resolves Thomson Reuters namespace prefixes, raising `MalformedResponse` when the node is absent.\n\n| `string_utils.py`\n| `remove_accents(text)` (NFKD fold; leaves `ł`, `ø`, `æ`, `œ`, `ß` alone), `is_matching_fuzzy(a, b, accuracy=100)` (`thefuzz.token_set_ratio`), `is_full_name_match(given, expected)` — the accent-folded, order-insensitive, exact-token name comparison used for `IDNameMatch`.\n\n| `dict_utils.py`\n| `remove_empty_values(d)` — drops falsy values; every `TaskInput.from_dict` runs it first, so `\"\"`, `null`, `0` and `false` are treated as absent.\n\n| `time_utils.py`\n| `round_time_diff(start_time, end_time)` — elapsed seconds, 2 dp.\n\n| `metric_utils.py`\n| `send_metric(metrics_namespace, name, value, unit=SECONDS, process_name='automatic-uw-tool')` — publishes through `pyb-sdk`'s `CriticalMetricNamespace` with `PybUserProcess.UNDERWRITING` / `PybService.INTG`. Swallows and logs any error, so a metrics outage never fails a request or a task.\n|===\n\n[WARNING]\n====\n`remove_empty_values` means a legitimately falsy input value cannot reach a task — for example\n`test=false` is simply dropped (which is harmless, since absence and `False` mean the same\nthing there). Keep that in mind when adding boolean or numeric task inputs.\n====\n\n== Test helpers\n\n`development/unit_tests/utils_/test_dict_utils.py` and `test_string_utils.py` cover the helpers\nabove; mockito is used for all mocking. See xref:contributing/testing.adoc[].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"operations/local-development.adoc","title":"Local development","description":"Bootstrap the project, run both containers, and use the Makefile targets.","url":"https://docx.payability.com/automatic-uw-tool/operations/local-development.html","navOrder":29,"source":"= Local development\n:navtitle: Local development\n:description: Bootstrap the project, run both containers, and use the Makefile targets.\n\n== Prerequisites\n\n* Python {python-version} (`.python-version` pins `3.9`; `requires-python = \">=3.9,<3.10\"`).\n* https://docs.astral.sh/uv/[uv] for dependency management.\n* Git access to the private `pyb-sdk` and `EventLoggerPySDK` repositories — a credential helper\n  or `GIT_TOKEN`.\n* AWS credentials for the target environment (needed by `config_setup.py` to read Secrets\n  Manager, and at runtime for DynamoDB/S3/SWF/EventBridge).\n\n== Quick start\n\n[source,bash]\n----\nmake setup ENV_NAME=mydev     # install deps + generate settings + fetch secrets\nmake run-api                  # start the Web API\nmake run-worker               # start the SWF worker (separate terminal)\nmake test\n----\n\n`ENV_NAME` defaults to `dev`, and the Makefile exports `ENV_FOR_DYNACONF` to match it — the two\n*must* agree or Dynaconf reads the wrong environment block.\n\n== Makefile targets\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Target | Does\n\n| `help` | List the targets (default goal).\n| `install` | `uv sync` — create `.venv` and install runtime + dev dependencies.\n| `lock` | `uv lock` — regenerate `uv.lock` from `pyproject.toml`.\n| `settings` | `python settings_setup.py` → `config/settings.json`.\n| `secrets` | `python config_setup.py` → `config/.secrets.json` + Thomson Reuters certificate material (needs AWS credentials).\n| `setup` | `install` + `settings` + `secrets`.\n| `run-api` | `uvicorn web_api_app:app --reload --host $(API_HOST) --port $(API_PORT)` (defaults `0.0.0.0:8000`).\n| `run-worker` | `python -u swf_app.py`.\n| `test` | `uv run pytest` — the same invocation CI runs.\n| `coverage` | `test`, then point at `htmlcov/index.html`.\n| `docker-build` | Build both images via `deployment/docker-compose.yml`.\n| `clean` | Remove `.venv`, caches and test/coverage artifacts (keeps generated config).\n| `clean-config` | Remove `config/settings.json`, `config/.secrets.json` and the TR PEM/PFX material.\n|===\n\n== Manual bootstrap (without make)\n\n[source,bash]\n----\nexport GIT_TOKEN=<github-pat-with-repo-read>\nexport ENV_NAME=mydev            # \"release\" == production\nexport ENV_FOR_DYNACONF=$ENV_NAME\n\nuv sync                          # or: pip install -r requirements.txt\npython settings_setup.py         # → config/settings.json\npython config_setup.py           # → config/.secrets.json (+ TR certs); needs AWS creds\n\nuv run uvicorn web_api_app:app --reload --host 0.0.0.0 --port 8000\nuv run python -u swf_app.py\n----\n\n`pre_requirements.txt` is the minimal dependency subset for the two bootstrap scripts when a full\ninstall is not wanted.\n\n== Dependency management notes\n\n`pyproject.toml` carries several deliberate constraints — do not \"clean them up\" without reading\nthe comments there:\n\n* `package = false` — this repository is a deployable app, not an installable library.\n* `build-constraint-dependencies = [\"setuptools<81\"]` — `pyb-sdk` is a PyScaffold package whose\n  `setup.py` imports `pkg_resources`, removed in setuptools 81.\n* `setuptools<81` as a *runtime* dependency — `dynaconf` 3.1.8 imports `pkg_resources` at runtime\n  and uv venvs do not ship setuptools by default.\n* `override-dependencies = [\"requests==2.31.0\"]` — `pyb-sdk` hard-pins `requests==2.28.1`, which\n  this project overrides graph-wide.\n* `[tool.uv.sources]` — `pybsdk` from the `develop-py39` branch, `eventlogger` from GitHub.\n\n`requirements.txt` is the pip-based equivalent used by the Docker images and CodeBuild; keep it in\nstep with `pyproject.toml` when you change a runtime dependency.\n\n== Running against a dev environment\n\nA dev `ENV_NAME` gives you: Thomson Reuters and Au10tix sandboxes, `dev-payability-datalake`,\nenvironment-suffixed DynamoDB tables, the environment name as the SWF domain and EventBridge bus,\n`/docs` and `/redoc` enabled, and the `/automatic-uw-tool-web-api` root path. See\nxref:operations/configuration.adoc[].\n\nBecause the SWF domain is per-environment, a locally running worker polls only its own\nenvironment's task list — two developers on different `ENV_NAME` values do not steal each\nother's activities. Two workers on the *same* `ENV_NAME`, however, will.\n\n== Local Entrust webhooks\n\n`POST /entrust/webhook` needs to be reachable by Onfido and signed with\n`ENTRUST_WEBHOOK_TOKEN`. For local work either\n\n* point a tunnel at your port and register it as an Onfido webhook using the same shared secret,\n  or\n* replay a captured delivery yourself, computing the signature over the exact bytes:\n\n[source,bash]\n----\nBODY='{\"payload\":{\"resource_type\":\"workflow_run\",\"action\":\"workflow_run.completed\",\"object\":{\"id\":\"<workflow_run_id>\"}}}'\nSIG=$(printf '%s' \"$BODY\" | openssl dgst -sha256 -hmac \"$ENTRUST_WEBHOOK_TOKEN\" | awk '{print $2}')\ncurl -X POST http://localhost:8000/entrust/webhook \\\n  -H \"Content-Type: application/json\" -H \"X-SHA2-Signature: $SIG\" -d \"$BODY\"\n----\n\nThe `workflow_run_id` must already exist as a `TaskExecutionTable` row (create one through\n`POST /entrust/sessions`), otherwise the webhook acknowledges and ignores it.\n\n== Docker\n\n[source,bash]\n----\nexport ENV_NAME=<env> AWS_ACCOUNT=<acct> GIT_TOKEN=<pat> \\\n       ROOT_PATH=/automatic-uw-tool-web-api NAME_TAG=automatic-uw-tool\nexport ENV_FOR_DYNACONF=$ENV_NAME\ndocker-compose -f deployment/docker-compose.yml build\n----\n\nSee xref:operations/deployment.adoc[] for what the images contain.\n\n== Do not commit\n\n`config/settings.json`, `config/.secrets.json`, and any `.pem` / `.pfx` / `.base64` /\n`pfx_password.txt` material under `config/thomson_reuters/`. They are generated and/or secret.\n`make clean-config` removes them.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"operations/configuration.adoc","title":"Configuration & secrets","description":"How Dynaconf loads settings per environment, and how secrets reach the containers.","url":"https://docx.payability.com/automatic-uw-tool/operations/configuration.html","navOrder":30,"source":"= Configuration & secrets\n:navtitle: Configuration & secrets\n:description: How Dynaconf loads settings per environment, and how secrets reach the containers.\n\n== Loading model\n\nAt runtime configuration is a single `settings` object built by Dynaconf from two *generated*\nfiles:\n\n[source,python]\n----\nfrom dynaconf import Dynaconf\nsettings = Dynaconf(environments=True, settings_files=['config/.secrets.json', 'config/settings.json'])\n----\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| File | Produced by | Contents\n\n| `config/settings.json` | `settings_setup.py` | Non-secret, per-environment configuration.\n| `config/.secrets.json` | `config_setup.py` | Secrets fetched from AWS Secrets Manager.\n|===\n\nNeither file is committed. `ENV_FOR_DYNACONF` selects which environment block Dynaconf reads and\nmust equal `ENV_NAME`. Import the object anywhere as `from config.config import settings`.\n\n[IMPORTANT]\n====\nApplication code reads configuration *only* through `settings` — never `os.getenv`, never a\nhardcoded URL, table name or account id. New non-secret values go in `settings_setup.py`; new\nsecrets go into the `automatic_uw_tool` Secrets Manager entry (see below).\n====\n\n== `settings_setup.py`\n\nReads `ENV_NAME` (required, else `ValueError`) and writes `config/settings.json` with a `default`\nblock plus one environment block. `{prod-env}` is production; every other name is a dev\nenvironment with environment-suffixed resources and vendor sandboxes.\n\nThe `default` block holds environment-independent values: `ENV_NAME`, `PROJECT_NAME`,\n`ENTRUST_API_URL`, `ENTRUST_WORKFLOW_ID`, the Thomson Reuters permissible-purpose codes and\ncertificate paths, the SWF coordinates, and `SENTRY_DSN`.\n\nFor non-`{prod-env}` environments a numeric *GCI* is derived from the environment name\n(`__generate_gci` — the first six digits of its SHA-1 hex digest) and used as\n`B2B_TASK_MANAGER_GCI`; production uses `1`.\n\nThe full list of keys, with production and dev values, is in\nxref:reference/settings.adoc[].\n\n== `config_setup.py`\n\nRuns during the build (and locally via `make secrets`); needs AWS credentials. It selects\nproduction versus dev from its first argument — `production` when invoked as\n`python config_setup.py codepipeline/automatic-uw-tool` (CodeBuild passes\n`$CODEBUILD_INITIATOR`), otherwise `dev`.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Secrets Manager entry | Becomes\n\n| `automatic_uw_tool`\n| The whole of `config/.secrets.json`. Every key added to this secret is written verbatim and becomes `settings.<KEY>`.\n\n| `prod/au10tix/token` or `test/au10tix/token`\n| `AU10TIX_TOKEN` inside `.secrets.json`.\n\n| `/thomsonreuters/s2s/production/v3` (prod)\n| `config/thomson_reuters/file.pfx.base64` + `pfx_password.txt`, which the build converts to `cert.pem` / `key.pem` with `openssl`.\n\n| `thomsonreuters/s2s/test` (dev)\n| `config/thomson_reuters/cert.pem` and `key.pem` written directly (the secret stores the already-extracted PEMs under `crt.pem` / `key.pem`, with escaped newlines restored).\n|===\n\nThomson Reuters secrets are referenced by *name*, not ARN: a bare name resolves within the\ncaller's account and region and stays stable across rotation, whereas the ARN's 6-character\nsuffix changes. The two environments live in different AWS accounts, which the ambient\ncredentials already select.\n\n== Secrets consumed at runtime\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Secret | Used by\n\n| `AU10TIX_TOKEN` | xref:components/au10tix.adoc[Au10tix client]\n| `THOMSON_REUTERS_USER`, `THOMSON_REUTERS_PASSWORD` | xref:components/thomson-reuters.adoc[CLEAR client] (plus the PEM cert/key on disk)\n| `PAYABILITY_AUTH_TOKEN` | `PybApiAuth` in every internal client — exchanged for an `X-Session-Token` (carrying `PermCode.INT_LOGIN`, which the document-upload route requires)\n| `ENTRUST_TOKEN` | xref:components/entrust.adoc[Entrust/Onfido client]\n| `ENTRUST_WEBHOOK_TOKEN` | The `X-SHA2-Signature` HMAC on `POST /entrust/webhook`\n| `HUBSPOT_PRIVATE_APP_TOKEN` | xref:components/hubspot.adoc[HubSpot client]\n| `EVENT_LOGGER_KEY` | xref:components/event-logger.adoc[Supplier event logger] — with the non-secret `EVENT_LOGGER_URL` / `EVENT_LOGGER_INSTANCE_ID`\n|===\n\n== Environments\n\n[cols=\"1,2,2,3\",options=\"header\"]\n|===\n| Env | `ENV_NAME` | AWS account | Notes\n\n| Dev | any dev slug | `{dev-aws-account}` | Vendor sandboxes, environment-suffixed resources, `/docs` enabled.\n| Prod | `{prod-env}` | `{prod-aws-account}` | Live vendors, `/docs` disabled, app mounted at `/`.\n|===\n\nSelected differences:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Setting | `{prod-env}` | dev (`<env>`)\n\n| `THOMSON_REUTERS_API_URL` | `s2s.thomsonreuters.com/api` | `s2s.beta.thomsonreuters.com/api`\n| `AU10TIX_API_URL` | `payability.au10tixservices.com/Au10tixBos4` | `payability.au10tixservicesstaging.com/Au10tixBos4`\n| `SWF_DOMAIN` | `B2B` | `<env>`\n| `S3_DATALAKE_BUCKET` | `payability-datalake` | `dev-payability-datalake`\n| `EVENT_BUS_NAME` | `prod_workers` | `<env>`\n| `EVENT_LOGGER_URL` | `api.payability.com/eventlogger/` | an API Gateway stage URL\n| `*_TABLE_NAME` | `automatic-uw-tool-*` | `automatic-uw-tool-*-<env>`\n| `FAST_API_DOCS_URL` / `_REDOC_URL` | `None` | `/docs` / `/redoc`\n| `FAST_API_ROOT_PATH` | `/` | `/automatic-uw-tool-web-api`\n| `FAST_API_CORS_ALLOW_ORIGIN` | `.*.payability.com` | `.*.dev.payability.com`\n| `B2B_TASK_MANAGER_GCI` | `1` | SHA-1-derived digits\n|===\n\n`ENTRUST_API_URL` and `ENTRUST_WORKFLOW_ID` are currently the *same* in both environments (they\nlive in the `default` block), so a dev environment exercises the real Onfido workflow with\nwhatever `ENTRUST_TOKEN` its account's secret provides.\n\n== Gotchas\n\n* *`ENV_FOR_DYNACONF` must match `ENV_NAME`.* If they diverge, Dynaconf silently falls back to\n  `default`-only values — you will see missing-attribute errors for anything environment-specific.\n* *Generated files are not editable configuration.* Change `settings_setup.py` or the Secrets\n  Manager entry and regenerate; do not hand-edit `settings.json` for a deploy.\n* *`{prod-env}` is production.* Any other `ENV_NAME` is treated as dev.\n* *The TR certificate must exist before the client is constructed* — `ThomsonReutersClient`\n  resolves the cert/key paths in `__init__`, and any handler that builds a `TaskFactory`\n  constructs one, so a missing PEM breaks endpoints that never touch Thomson Reuters.\n* *A missing secret surfaces late.* `settings.SOME_KEY` raises only when first read, which for\n  webhook secrets means at delivery time — `ENTRUST_WEBHOOK_TOKEN` is read defensively with\n  `getattr(settings, …, None)` and logged as a configuration error rather than crashing.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"operations/deployment.adoc","title":"Deployment","description":"The CodeBuild pipeline, the two Docker images, and the ECS/CodeDeploy artifacts.","url":"https://docx.payability.com/automatic-uw-tool/operations/deployment.html","navOrder":31,"source":"= Deployment\n:navtitle: Deployment\n:description: The CodeBuild pipeline, the two Docker images, and the ECS/CodeDeploy artifacts.\n\n== Pipeline overview\n\n[source]\n----\nAWS CodePipeline (trigger)\n  └─► AWS CodeBuild  (buildspec.yml)\n        install    : Python 3.9, remove .python-version\n        pre_build  : settings_setup.py → pip install → config_setup.py\n                     → openssl (prod only) → pytest → ECR login\n        build      : docker-compose build → tag images → write deploy artifacts\n        post_build : docker push both images to ECR\n  └─► AWS CodeDeploy (ECS)\n        register the new task definition and roll out via appspec.yaml\n----\n\n== Environments and accounts\n\nAccount selection happens in `buildspec.yml` from `ENV_TYPE`:\n\n[source,bash]\n----\nif [ \"$ENV_TYPE\" = \"dev\" ]; then\n  export AWS_ACCOUNT=605668041275\n  export ROOT_PATH=/automatic-uw-tool-web-api\nelse\n  export AWS_ACCOUNT=625663093496\n  export ROOT_PATH=/automatic-uw-tool-web-api\nfi\nexport SWF_IMAGE_URI=${AWS_ACCOUNT}.dkr.ecr.us-east-1.amazonaws.com/automatic-uw-tool-swf-worker\nexport API_IMAGE_URI=${AWS_ACCOUNT}.dkr.ecr.us-east-1.amazonaws.com/automatic-uw-tool-web-api\nexport APP_NAMES=${APP_NAMES:-\"automatic-uw-tool-swf-worker automatic-uw-tool-web-api\"}\n----\n\n`APP_NAMES` defaults to both apps. Each token must contain `swf` or `api` (that substring is what\nthe build phase matches on) and doubles as the imagedefinitions artifact filename.\n\nEnvironment-to-resource mapping: xref:operations/configuration.adoc[].\n\n== CodeBuild phases\n\nRuntime: Python {python-version}. `GIT_TOKEN` comes from the Secrets Manager entry\n`aws-codebuild-token-eva` and is needed to install the private `pyb-sdk`.\n\n=== install\n\nRemoves `.python-version`. `uv` commits a bare `3.9` pin, but pyenv (which backs CodeBuild's\n`runtime-versions`) needs an exact patch version and lets that file win — newer CodeBuild images\notherwise fail with ``pyenv: version `3.9' is not installed``. Local `uv` workflows recreate the\nfile.\n\n=== pre_build\n\n. `python settings_setup.py` → `config/settings.json`.\n. `pip install -r requirements.txt` with `PIP_CONSTRAINT` pinning `setuptools<81` + `wheel`, so\n  `pyb-sdk`'s `pkg_resources` import keeps working during build isolation.\n. `export ENV_FOR_DYNACONF=$ENV_NAME`.\n. `python config_setup.py $CODEBUILD_INITIATOR` → `config/.secrets.json` and the TR material.\n. *Production only* — decode the base64 PFX and extract the PEMs:\n+\n[source,bash]\n----\nPFX_PASSWORD=$(cat config/thomson_reuters/pfx_password.txt)\ncat config/thomson_reuters/file.pfx.base64 | base64 --decode > config/thomson_reuters/file.pfx\nopenssl pkcs12 -in config/thomson_reuters/file.pfx -nocerts -out config/thomson_reuters/key_pass.pem -passin pass:$PFX_PASSWORD -passout pass:$PFX_PASSWORD\nopenssl pkcs12 -in config/thomson_reuters/file.pfx -clcerts -nokeys -out config/thomson_reuters/cert.pem -passin pass:$PFX_PASSWORD\nopenssl rsa -in config/thomson_reuters/key_pass.pem -out config/thomson_reuters/key.pem -passin pass:$PFX_PASSWORD\n----\n+\nIn dev, `config_setup.py` has already written `cert.pem` and `key.pem` directly.\n. `python -m pytest` — unit tests and coverage. The build fails if coverage drops below\n  {coverage-gate} (`pyproject.toml`).\n. `aws ecr get-login-password | docker login` for the target account.\n\n=== build\n\n`docker-compose -f deployment/docker-compose.yml build`, then per app in `APP_NAMES`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| App | Tag and artifact\n\n| `*swf*`\n| Tag `$SWF_IMAGE_URI:$ENV_NAME`; write `artifacts/<app>.json` = `[{\"name\":\"app\",\"imageUri\":\"…:<ENV_NAME>\"}]`.\n\n| `*api*`, non-`{prod-env}`\n| Tag `$API_IMAGE_URI:$ENV_NAME`; same imagedefinitions format.\n\n| `*api*`, `{prod-env}`\n| Tag `$API_IMAGE_URI:release`; write `artifacts/imageDetail.json` = `{\"ImageURI\":\"…:release\"}` for the ECS blue/green CodeDeploy.\n|===\n\nThe whole block is skipped when `ENV_NAME` is unset.\n\n=== post_build\n\n`docker push` both images to ECR under the `$ENV_NAME` tag.\n\n=== Reports and artifacts\n\n* Reports: `report.xml` (JUnit), `coverage.xml` (Cobertura).\n* Artifacts: everything in `./artifacts/*` with `discard-paths: yes`.\n\n== Docker images\n\nBoth images start from `public.ecr.aws/docker/library/python:3.9-slim`, install `git` (for the\ngit-based private dependencies), copy the repository, and `pip install -r requirements.txt`.\n\n[cols=\"1,2,2,2\",options=\"header\"]\n|===\n| Image | Dockerfile | Workdir | `CMD`\n\n| Web API\n| `deployment/web_api/Dockerfile`\n| `/automatic-uw-tool-web-api`\n| `uvicorn web_api_app:app --reload --host 0.0.0.0 --port 80 --log-level debug`\n\n| SWF worker\n| `deployment/swf_worker/Dockerfile`\n| `/automatic-uw-tool-swf-worker`\n| `python -u swf_app.py`\n|===\n\nBuild args wired by `deployment/docker-compose.yml`: `ENV_FOR_DYNACONF` (from `ENV_NAME`),\n`AWS_ACCOUNT`, `GIT_TOKEN`, and `ROOT_PATH` (Web API only).\n\n[WARNING]\n====\nThe Web API image runs uvicorn with `--reload` and `--log-level debug`, which are development\nflags: the reloader spawns a watcher process and debug logging is verbose. Worth revisiting when\nhardening production.\n\nNote also that the generated `config/settings.json` and `config/.secrets.json` are produced in\n`pre_build` and therefore *baked into the image* by `COPY . .`. The image is environment-specific,\nwhich is why it is tagged with `$ENV_NAME`.\n====\n\n== ECS artifacts\n\n=== `artifacts/taskdef.json` — Fargate task definition (Web API)\n\nFamily `automatic-uw-tool`, one container named `api` on port 80, 512 CPU / 2048 MB, awslogs to\n`/ecs/automatic-uw-tool` in `us-east-1`. `<IMAGE1_NAME>` and `<TASK_DEFINITION>` are substituted\nat deploy time.\n\n[source,json]\n----\n\"family\": \"automatic-uw-tool\",\n\"taskRoleArn\": \"arn:aws:iam::625663093496:role/automatic-uw-tool-role\",\n\"executionRoleArn\": \"arn:aws:iam::625663093496:role/ecsTaskExecutionRole\",\n\"networkMode\": \"awsvpc\",\n\"containerDefinitions\": [\n    {\n        \"name\": \"api\",\n        \"image\": \"<IMAGE1_NAME>\",\n----\n\n=== `artifacts/appspec.yaml` — CodeDeploy ECS spec\n\nBlue/green deployment of the ECS service, routing the ALB to container `api` port 80.\n\n[source,json]\n----\n{\n  \"version\": 1,\n  \"Resources\": [\n    {\n      \"TargetService\": {\n        \"Type\": \"AWS::ECS::Service\",\n        \"Properties\": {\n          \"TaskDefinition\": \"<TASK_DEFINITION>\",\n          \"LoadBalancerInfo\": { \"ContainerName\": \"api\", \"ContainerPort\": 80 }\n        }\n      }\n    }\n  ]\n}\n----\n\n[NOTE]\n====\n`taskdef.json` and `appspec.yaml` describe only the *Web API* service. The SWF worker is deployed\nas its own ECS service from its generated imagedefinitions artifact — it needs no load balancer,\nbecause it long-polls SWF.\n====\n\n== Building locally\n\n[source,bash]\n----\nexport ENV_NAME=<env> AWS_ACCOUNT=<acct> GIT_TOKEN=<pat> \\\n       ROOT_PATH=/automatic-uw-tool-web-api NAME_TAG=automatic-uw-tool\nexport ENV_FOR_DYNACONF=$ENV_NAME\npython settings_setup.py\npython config_setup.py            # needs AWS credentials\ndocker-compose -f deployment/docker-compose.yml build\n----\n\nOr `make docker-build` — see xref:operations/local-development.adoc[].\n\n== Runtime dependencies\n\n`requirements.txt` (Python {python-version}): `fastapi==0.110.0`, `uvicorn`, `boto3==1.33.2`,\n`dynaconf==3.1.8`, `requests==2.31.0`, `xmltodict`, `PyJWT`, `sentry-sdk`, `thefuzz` (name\nmatching), `arrow`, `coolname` (worker thread names), `werkzeug` (HTTP status phrases),\n`hubspot-api-client`, plus the private `pyb-sdk` and `eventlogger` installed from GitHub with\n`GIT_TOKEN`.\n\n== Rolling back\n\nImages are tagged by environment (`:release`, `:<env>`), not by commit, so \"roll back\" means\nredeploying a previous *task definition revision* in ECS/CodeDeploy rather than re-pulling an\nolder tag. Keep that in mind before triggering a rebuild on a bad commit — the rebuild\noverwrites the tag.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"operations/observability.adoc","title":"Observability","description":"The metrics, logs, audit trails and error reporting available for this service.","url":"https://docx.payability.com/automatic-uw-tool/operations/observability.html","navOrder":32,"source":"= Observability\n:navtitle: Observability\n:description: The metrics, logs, audit trails and error reporting available for this service.\n\n== CloudWatch metrics\n\nPublished through `utils/metric_utils.py` → `pyb-sdk` `CriticalMetricNamespace`\n(`PybUserProcess.UNDERWRITING`, `PybService.INTG`, `process_name = automatic-uw-tool`). Metric\npublication never fails a request or a task — errors are caught and logged.\n\n=== Worker (`swf_app.py`)\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Metric | Unit | Emitted\n\n| `\\{process_name}_failure` | count | Any exception while building or executing an activity (only when the task object was built).\n| `\\{process_name}_success_time_taken` | seconds | Successful activity, wall-clock from poll to completion.\n|===\n\n`\\{process_name}` is `task.get_process_name()`, so both Au10tix and Entrust report under\n`validate_identity_document`.\n\n=== Web API\n\nOne timing metric per endpoint, emitted on *every* call including error paths:\n\n* `execute_endpoint_time_taken`\n* `get_task_result_endpoint_time_taken`\n* `get_task_status_endpoint_time_taken`\n* `delete_task_results_endpoint_time_taken`\n* `update_final_statuses_endpoint_time_taken`\n* `get_tools_endpoint_time_taken`\n* `create_entrust_session_endpoint_time_taken`\n\nBecause success and failure share a metric name, latency alone will not tell you a call failed —\npair these with the Sentry stream and the task statuses.\n\n== Logs\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Stream | Contents\n\n| Web API (`/ecs/automatic-uw-tool`)\n| `WEB REQUEST LOGGER` lines — `[\\{login_id}:\\{source}] \\{path} (\\{payload})` — plus handler logs. The Entrust session handler logs a full narrative (authorization, reuse, applicant/run creation, persistence) keyed by the caller's login id.\n\n| SWF worker\n| Task loggers, one per task, prefixed with the correlation id: `\\{correlation_id} \\| \\{task_name} \\| \\{LEVEL} \\| \\{asctime} - \\{message}`. Five threads interleave, so always filter by correlation id.\n|===\n\n[WARNING]\n====\nRequest payloads are logged verbatim, so `validate_personal_information` inputs put PII (name,\ndate of birth, SSN, address) into the Web API log stream. `ThomsonReutersDataNotMatchEvent` and\n`ThomsonReutersNotWorkingEvent` likewise carry `task_input` in their `Detail`. Treat both as\nsensitive.\n====\n\n== Sentry\n\n`sentry_sdk.init` runs in both entry points:\n\n* Web API — `environment = settings.ENV_NAME`, plus `SentryAsgiMiddleware` for unhandled request\n  errors.\n* Worker — `environment = settings.SENTRY_ENV` (`production` in `{prod-env}`, the env name\n  otherwise), `traces_sample_rate = 1.0`, and an explicit `capture_exception()` for every failed\n  activity.\n\nAlso reported deliberately: HubSpot sync failures and supplier-event failures — `HubSpotClient`\nand `SupplierEventLogger` both capture their own exceptions because they swallow them.\n\n== Audit trails inside the data\n\nObservability is not only CloudWatch:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Source | Answers\n\n| `TaskExecutionTable`\n| Did the execution finish? `status`, `create_ts`/`update_ts`, `requested_by`, `source`, the failure `output` (`{'error': …}`), and for Entrust the `document_actions` counts.\n\n| `ChangelogTable`\n| Who changed a verdict or a tool, when, and under which `app_version` — every `FinalStatusTable` and `ToolTable` mutation appends a row.\n\n| `FinalStatusTable`\n| Verdict history: versions are kept per `correlation_id`, sorted by `created_at`, with `created_by` / `updated_by` (`automatic-uw-tool`, `override`, or a login id).\n\n| S3 datalake\n| The raw vendor evidence behind a verdict — for Entrust, including every workflow step's input and output.\n\n| Internal EventLogger\n| The supplier's Entrust lifecycle as supplier-timeline events (`in_progress` → `verified` / `in_review` / `denied`), with `workflow_run_id` and `verdict` in `extra`. Best effort — see xref:components/event-logger.adoc[].\n\n| SWF console\n| Activity input, result and failure `reason`/`details` per run. The Entrust task's compact result (status, verdict, document counts) is designed to be readable there.\n|===\n\n== Health checks\n\n`GET /health-check` returns `{\"status\": \"OK\"}` unauthenticated — used by the ALB target group.\nIt is a liveness probe only: it does not verify DynamoDB, SWF or vendor connectivity.\n\nThe worker exposes no health endpoint. Its liveness is visible as SWF activity throughput and the\n`_success_time_taken` metric; a wedged worker shows up as tools stuck `PENDING` (surfaced as\n`ERROR` by `GET /tools` after 300 s).\n\n== Suggested alarms\n\nNot currently defined in this repository, but the useful signals are:\n\n* `\\{process}_failure` count above zero over a short window, per process.\n* `\\{process}_success_time_taken` p95 rising — vendor slowdown.\n* Sentry error-rate spikes on `ThomsonReutersNotWorking`, `RetryException`, or `HTTPError` from\n  the internal document API (an Entrust upload rejection).\n* Tools left `PENDING` beyond the 300 s window, which indicates the worker is not draining the\n  task list.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"operations/troubleshooting.adoc","title":"Troubleshooting","description":"Symptom-to-cause table for the failures this service actually produces.","url":"https://docx.payability.com/automatic-uw-tool/operations/troubleshooting.html","navOrder":33,"source":"= Troubleshooting\n:navtitle: Troubleshooting\n:description: Symptom-to-cause table for the failures this service actually produces.\n\nStart from the `correlation_id`. It is the key into `TaskExecutionTable`, `FinalStatusTable`,\n`ToolTable`, the S3 key, the log prefix and the SWF run — see xref:data/dynamodb.adoc[].\n\n== Startup and configuration\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Symptom | Cause and fix\n\n| Attribute errors for environment-specific settings, or unexpectedly `default` values\n| `ENV_FOR_DYNACONF` does not match `ENV_NAME`. Export both to the same value and regenerate `config/settings.json`.\n\n| `ValueError: Environment ENV_NAME value is required`\n| `settings_setup.py` ran without `ENV_NAME`.\n\n| Any endpoint 500s at construction time, even ones unrelated to Thomson Reuters\n| `ThomsonReutersClient.__init__` resolves the cert/key paths, and several handlers build a full `TaskFactory`. Run `make secrets` (dev writes the PEMs directly; production extracts them from the PFX during the build).\n\n| `pyenv: version '3.9' is not installed` in CodeBuild\n| `.python-version` is overriding `runtime-versions`. The `install` phase already removes it — check it was not re-added inside the build.\n\n| `ModuleNotFoundError: pkg_resources` during install\n| setuptools ≥ 81. The pins in `pyproject.toml` (`setuptools<81`, `build-constraint-dependencies`) and `PIP_CONSTRAINT` in `buildspec.yml` exist for this.\n|===\n\n== HTTP responses\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Status | Likely cause\n\n| `400` \"Source is missing from auth token\"\n| The auth API response has no `source` claim — the token was minted by a service that does not set one.\n\n| `400` \"Supplier key from auth token does not match…\"\n| A supplier-dashboard caller targeted another supplier.\n\n| `400` `{\"error_class\": \"ValidationError\", …}`\n| An input validator rejected the payload; `error_body.incorrect_fields` names each field. Note the task row was already written and is now `failed`.\n\n| `400` `{\"error_class\": \"CannotRunWorkflow\", \"error_body\": {\"reason\": \"Task already running\"}}`\n| An SWF workflow with the same deterministic `workflowId` is still running for that supplier and process. Wait for it, or use `force_new_execution` (which does not change the workflow id — it only bypasses the DynamoDB dedup).\n\n| `403` \"Access forbidden.\"\n| Either the source is not in the task's `get_available_process_sources()`, or a non-internal caller hit an internal-only endpoint (`GET /tasks/result`, `GET /tasks/\\{id}`, `DELETE /tasks/result`, `PUT …/final_statuses`).\n\n| `403` \"Forbidden.\"\n| The caller has none of the required perms and is not `INT_SUPER_USER`.\n\n| `404` on `GET /tasks/result`\n| No `processed` *or* `failed` execution exists for that supplier + person + process. A `pending` run does not count.\n\n| `409` from `JWTAuthorizer`\n| A `KeyError` reading the auth payload — a required claim (usually `login_id`) is missing.\n\n| `409` on an Entrust reprocess\n| SWF refused the run; the message carries the B2B error.\n\n| `429`\n| Rate limit: 3 executions per supplier + process (supplier dashboard), or 3 Entrust sessions per person (supplier dashboard / onboarding).\n\n| `500` \"Something went wrong with auth API…\"\n| `get_user_perms` failed during the permission check.\n\n| `502` on `POST /entrust/sessions`\n| Either the Payability internal API or Onfido failed. A 4xx from Onfido is passed through instead, with the upstream body under `entrust_error`.\n|===\n\n== Tools and statuses\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Symptom | Cause and fix\n\n| A tool is `ERROR` with `error_message = \"Task Timeout\"`\n| It sat `PENDING` for more than 300 s and `GET /tools` flipped it. Usually the worker is not draining `{swf-task-list}`, or the vendor poll exceeded the window. Check the task row and the SWF run.\n\n| A tool stays `PENDING` after an Entrust run finished\n| The run status is `review` — this is by design. The tool resolves on the later terminal completion.\n\n| A tool is `NOT_RUN` although a process ran\n| `GET /tools` auto-created the row from `SUPPORTED_TOOLS` and no execution has written it yet — or, for a background report, note that `download_business_background_report` has no tool at all.\n\n| The task is `processed` but the verdict looks wrong\n| Check `FinalStatusTable` history (versions are kept) and `ChangelogTable` for a manual override; `default_result_status` on the detailed result shows what the vendor actually said.\n|===\n\n== Worker and vendor failures\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Symptom | Cause and fix\n\n| `RetryException: The number of retries has been exceeded.`\n| A vendor poll never became ready: Au10tix readiness (10 × 5 s), a Thomson Reuters report stuck on `204` (`Retry(total=20)`, 10 s). Retry the process later.\n\n| `ResultNotFound`\n| The vendor has no matching records (`GroupCount == 0`), or a multi-record person search matched no exact first/last name + SSN combination.\n\n| `MalformedResponse`\n| An expected node was missing. For the *business* report this can also surface as a raw `KeyError`, because that task addresses `ns2:`/`ns3:`/`ns10:` prefixes literally.\n\n| `MissingResponseSection`\n| The personal report lacked one of the seven required sections.\n\n| `Thomson Reuters not working` / `ThomsonReutersNotWorkingEvent`\n| A CLEAR call returned an HTTP error. The event lets the platform proceed without the check; the task still fails.\n\n| An activity dies at its start-to-close timeout with no failure recorded\n| Historically caused by an over-long SWF `reason`; `SWFClient` now truncates `reason` to 256 and `details` to 32768 chars. If you see it again, check for an exception raised *before* the responder ran.\n\n| Nothing is picked up at all\n| Domain/task-list mismatch (`SWF_DOMAIN` is the env name outside `{prod-env}`), or another worker on the same `ENV_NAME` is consuming the activities.\n|===\n\n== Entrust specifics\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Symptom | Cause and fix\n\n| Webhook returns `401`\n| Signature mismatch, missing `X-SHA2-Signature`, or `ENTRUST_WEBHOOK_TOKEN` unset (logged as a configuration error). Remember the HMAC covers the exact raw bytes.\n\n| Webhook returns `200 {\"status\":\"ignored\"}`\n| Not a `workflow_run.completed` event, no `object.id`, or no `TaskExecutionTable` row for that run (the session was never created here, or was purged).\n\n| Task fails on the document upload\n| The internal document API rejected the request; the raised `HTTPError` includes the upstream body (invalid `compliance_key`, unaccepted file, unknown id). SWF retries; the guards mean the archive/upload is not repeated once it has succeeded.\n\n| The run is `approved` but the documents are still `pending` in the document store\n| `uploaded_document_ids` was empty (nothing was uploaded — check `applicant_id` and `document_actions`), or `documents_approved` was already set by an earlier delivery.\n\n| No document was uploaded at all\n| The session recorded no `applicant_id`, or Onfido returned no files. Both are logged and treated as \"nothing to upload\", not as failures.\n\n| Re-running produced duplicate documents in the store\n| Expected: a reprocess archives everything existing and uploads a fresh set. See xref:api/tasks.adoc#entrust-reprocess[].\n\n| A redelivered webhook did nothing\n| The row was already `processed` — the idempotency guard returned the existing output.\n\n| HubSpot shows a stale status\n| HubSpot sync is best effort and swallows errors. Look in Sentry for `HubSpotClient` exceptions, and verify the contact has a `supplierkey` matching the supplier.\n|===\n\n== Useful queries\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Question | How\n\n| All executions of a process for a supplier\n| Query the `supplier_key-process_name-index` GSI on the task table.\n\n| Is this an Entrust or an Au10tix row?\n| Entrust rows carry `workflow_run_id` / `applicant_id` in `input`; Au10tix rows carry `document_id`.\n\n| The newest finished execution for a person\n| `TaskExecutionTable.get_latest_processed_task` (`processed` or `failed`), which is what `GET /tasks/result` uses.\n\n| Who changed a verdict\n| Scan `ChangelogTable` by `supplier_key`, sorted by `log_ts`; `type = FINAL_STATUS` and `action = UPDATE`.\n\n| The raw vendor evidence\n| `additional_data.full_result_s3_path` on the task row, in `S3_DATALAKE_BUCKET`.\n|===\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"contributing/python-standards.adoc","title":"Python standards","description":"The data-structure and production rules new code in this repository must follow.","url":"https://docx.payability.com/automatic-uw-tool/contributing/python-standards.html","navOrder":34,"source":"= Python standards\n:navtitle: Python standards\n:description: The data-structure and production rules new code in this repository must follow.\n\nThe reference that reviewers — human and AI — hold plans and diffs against. Goal: every new\nmodule models data with the strongest structure the runtime allows, and every design decision\nsurvives production load.\n\n[IMPORTANT]\n====\n*Runtime constraint: Python {python-version}* (`.python-version`,\n`requires-python = \">=3.9,<3.10\"`). That rules out `match` statements, `X | Y` union syntax in\nannotations (use `Optional`/`Union`, or `from __future__ import annotations`),\n`dataclass(slots=True)` (3.10+), `StrEnum` (3.11+) and `tomllib`. Built-in generics\n(`list[str]`, `dict[str, int]`) are fine.\n====\n\n== Pick the strongest data structure that fits\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Use case | Structure | Why\n\n| API request/response bodies\n| pydantic `BaseModel`\n| Validation at the boundary, self-documenting OpenAPI, no hand-rolled `input_validator` for new endpoints. Already in the graph via FastAPI 0.110.\n\n| Internal domain objects (task inputs/outputs, session records, result mappings)\n| `@dataclass(frozen=True)`\n| Immutable, hashable, typo-proof; replaces raw dicts crossing function boundaries. `EntrustFile` is the in-repo example.\n\n| Closed sets of values (statuses, tool types, decisions)\n| `Enum` (`class Decision(str, Enum)` for JSON-friendly values)\n| The repo already does this (`ToolStatus`, `ToolType`, `ProcessSource`); never introduce a bare string constant for a status.\n\n| Dict-shaped data you cannot restructure yet (DynamoDB items, legacy payloads)\n| `TypedDict` at minimum\n| Documents the shape and lets a type checker catch key errors.\n\n| Fixed small records in hot paths\n| `NamedTuple`\n| Cheap, immutable, destructurable.\n\n| Duck-typed injection seams (clients passed into `TaskFactory`)\n| `Protocol`\n| Lets tasks depend on an interface rather than a vendor class, and makes mockito tests honest.\n\n| Constants\n| `Final` annotations at module level\n| No magic numbers or strings inline.\n|===\n\nRules of thumb:\n\n* *No raw `dict` across a boundary.* A dict may exist inside a function; the moment it is\n  returned, passed or persisted, give it a dataclass/TypedDict/pydantic shape. Existing code\n  violates this in places — do not add new violations.\n* *Parse, don't validate.* Convert external shapes (webhook payloads, DynamoDB items, vendor\n  JSON) into typed objects once, at the edge, then pass the typed object around.\n* *Immutable by default.* `frozen=True` dataclasses; mutate with `dataclasses.replace`.\n* *Enums own their mapping logic.* Follow `ToolType.get_by_process_name` — classmethods on the\n  enum, not dict lookups scattered around callers.\n\n== Production and scaling patterns\n\n* *DynamoDB: query, never scan, in request paths.* `ToolTable` currently uses `scan` with filters\n  (`get_by_supplier_and_type`, `get_all_by_supplier`) even though `supplier_key` is the partition\n  key — a known hot spot. New access patterns must use `Key(...)` conditions against the PK/sort\n  key or a GSI, and must paginate on `LastEvaluatedKey` (see\n  `FinalStatusTable.__get_query_results` or `TaskExecutionTable.__get_query_results` for the\n  correct loop).\n* *Idempotency is mandatory for webhooks and events.* Handlers keyed by `correlation_id` /\n  `workflow_run_id` must tolerate redelivery: a repeated payload must not double-write S3,\n  double-emit EventBridge, or flip a terminal status backwards. Use status-transition guards or\n  conditional writes — the Entrust task's `documents_uploaded` / `documents_approved` flags and\n  its `processed` short-circuit are the reference implementation.\n* *`correlation_id` is the join key* across `TaskExecutionTable`, `FinalStatusTable`, `ToolTable`,\n  S3 keys and EventBridge payloads. Preserve it end to end; never mint a second one mid-flow.\n* *HTTP out: sessions, timeouts, retries.* Reuse a `requests.Session` per client, set an explicit\n  timeout on every call (no bare `requests.post`), and use the retry helper or an `HTTPAdapter`\n  retry for polling. `EntrustClient` shows the pattern; `Au10tixClient` and\n  `ThomsonReutersClient` predate it and lack both session reuse and timeouts — do not copy them.\n* *The Web API stays synchronous, thin and vendor-light.* Anything slow belongs on the worker.\n  The two exceptions (Entrust session creation, HubSpot sync) are fast single calls, and the\n  webhook hands off rather than processing.\n* *Fail loudly, once.* Side-effecting task logic goes through `task_failure_handling` so failures\n  mark the task `failed` and reach Sentry. Do not swallow exceptions to \"keep the loop alive\".\n  The one intentional exception is HubSpot, which is explicitly best-effort and documented as\n  such.\n* *Configuration through Dynaconf `settings` only.* New settings in `settings_setup.py`, secrets\n  in Secrets Manager via `config_setup.py`. Never `os.getenv` in app code, never a hardcoded\n  table name, URL or account id.\n* *S3 layout is part of the contract.* Datalake keys follow\n  `<vendor>/<process>/supplier_key=<k>/person_id=<p>/<timestamp>.json`; downstream consumers parse\n  these paths.\n\n== Conventions specific to this repository\n\n* Tasks live in packages with the exact file layout in xref:domain-model.adoc[]\n  (`implementation.py`, `input.py`, `output.py`, optional `input_validator.py`).\n* No direct I/O in `task/` — do not import `requests` or `boto3` there. Use the injected\n  `components/` clients.\n* Private methods use the double-underscore name-mangled form (`self.__do_thing`) throughout;\n  match the surrounding style.\n* Endpoint handlers subclass `BaseEndpointHandler`, log the request, and use `StopExecution` for\n  early exits rather than returning from nested helpers.\n* When you add a dependency to a task, wire it into *every* `TaskFactory` construction site —\n  `swf_app.py` plus the Web API handlers that build one.\n\n== Definition of done\n\n. Code follows the conventions above.\n. Unit tests added or updated under `development/unit_tests/` and passing, coverage ≥\n  {coverage-gate} (`pytest`, see xref:contributing/testing.adoc[]).\n. Any affected page in this documentation component updated\n  (xref:contributing/documentation.adoc[]).\n. No secrets or generated configuration committed.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"contributing/testing.adoc","title":"Testing","description":"The three test suites, how to run them, and what the coverage gate covers.","url":"https://docx.payability.com/automatic-uw-tool/contributing/testing.html","navOrder":35,"source":"= Testing\n:navtitle: Testing\n:description: The three test suites, how to run them, and what the coverage gate covers.\n\n== Suites\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Suite | Path | Runs\n\n| Unit\n| `development/unit_tests/`\n| In CI on every build. Mirrors the `task/`, `components/` and `interface/` trees. Mockito for mocking.\n\n| Integration\n| `development/integration_tests/`\n| Manually. Hits real vendor sandboxes — currently the Thomson Reuters CLEAR client.\n\n| End-to-end\n| `development/e2e_tests/`\n| Manually. Drives a *deployed* environment through its HTTP API and asserts on the resulting DynamoDB/S3 state.\n|===\n\n[WARNING]\n====\nNever run the integration or e2e suites in CI or unattended: they call live vendor sandboxes and\ndeployed environments, consume vendor quota, and write real rows.\n====\n\n== Running the unit tests\n\n[source,bash]\n----\nmake test            # uv run pytest\npython -m pytest     # identical; this is what CodeBuild runs\n----\n\n`pytest.ini` supplies the arguments:\n\n[source,ini]\n----\n[pytest]\naddopts = development/unit_tests --cov=. --junitxml=report.xml --cov-report term --cov-report xml --cov-report html\n----\n\nSo a bare `pytest` always targets `development/unit_tests` and produces `report.xml` (JUnit),\n`coverage.xml` (Cobertura) and `htmlcov/` (browse `htmlcov/index.html`, or `make coverage`).\n\n== Coverage gate\n\n`[tool.coverage.report] fail_under = 80` — the build fails below {coverage-gate}. Branch coverage\nis on.\n\nDeliberately omitted from measurement (`[tool.coverage.run] omit`): `development/**`,\n`interface/**`, `config_setup.py`, `settings_setup.py`, `swf_app.py`, `web_api_app.py`,\n`components/swf_client.py`, the four DynamoDB table clients, `components/auth/**` and `utils/**`.\n\n[NOTE]\n====\n`interface/**` being omitted means endpoint-handler tests improve confidence but not the coverage\nnumber — and that the gate is effectively about `task/` and `components/`. Write the handler tests\nanyway: `development/unit_tests/interface_/` exists precisely for the Entrust webhook, session and\nreprocess paths.\n====\n\n== Layout and naming\n\nUnit tests mirror the source tree with a trailing underscore on the top-level package to avoid\nimport clashes:\n\n[source]\n----\ndevelopment/unit_tests/\n├── components_/\n│   ├── entrust/test_client.py\n│   ├── test_hubspot_client.py\n│   ├── test_payability_internal_api.py\n│   └── test_swf_client.py\n├── interface_/web_api/endpoint_handling/\n│   ├── test_create_entrust_session_hubspot.py\n│   ├── test_execute_task_entrust_reprocess.py\n│   └── test_handle_entrust_webhook.py\n├── task_/\n│   ├── au10tix/<task_name>/test_implementation.py\n│   ├── entrust/<task_name>/test_implementation.py\n│   ├── thomson_reuters/<task_name>/test_implementation.py\n│   ├── test_process_factory.py\n│   └── test_task_factory.py\n└── utils_/\n    ├── test_dict_utils.py\n    └── test_string_utils.py\n----\n\nEvery directory needs an `__init__.py` — the suite relies on package imports.\n\n== What to test\n\n* *A new task* — one test per branch of `execute()`: happy path, each vendor failure, and the\n  final-status/tool/event writes. Assert on the injected mocks, not on AWS.\n* *Every new typed model* — edge parsing: a bad payload must produce a clear error.\n* *Idempotency, explicitly* — deliver the same webhook or activity twice and assert a single side\n  effect. The Entrust task's guards exist because of this rule.\n* *Input validators* — one case per rejected field, asserting the `incorrect_fields` message.\n* *Endpoint handlers* — auth/source/permission branches and the status codes they produce.\n\n== Fixtures and sample data\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Use\n\n| `examples/thomson_reuters_personal_background_check_response.json`\n| A recorded CLEAR personal report — also loaded by the handle task's `test=true` mode.\n\n| `examples/entrust_successfull_workflow_run.json`\n| A recorded Onfido workflow run.\n\n| `task/au10tix/handle_identity_document_validation_response/result.mock.json`\n| A mocked Au10tix result.\n\n| `development/e2e_tests/sample_identity_document.jpg`, `sample_fake_identity_document.jpg`\n| Images for the e2e identity-document runs.\n|===\n\n== Mocking style\n\nThe suite uses https://mockito-python.readthedocs.io/[mockito] (`when`, `verify`, `mock`), matching\nthe constructor-injection design: build the task with mocked clients, run `execute()`, verify the\ncalls. Because `TaskFactory` injects everything, no monkeypatching of module globals is needed —\nif a test needs that, the production code probably has a hidden dependency.\n\n== CI reporting\n\nCodeBuild publishes `report.xml` as a JUnit report and `coverage.xml` as a Cobertura report, so\nfailures and coverage trends are visible in the CodeBuild console. See\nxref:operations/deployment.adoc[].\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"contributing/documentation.adoc","title":"Writing these docs","description":"How this Antora component is laid out, how to preview it, and the conventions to follow.","url":"https://docx.payability.com/automatic-uw-tool/contributing/documentation.html","navOrder":36,"source":"= Writing these docs\n:navtitle: Writing these docs\n:description: How this Antora component is laid out, how to preview it, and the conventions to follow.\n\n`docs/` is an *Antora documentation component*, not a site. It contains no playbook: the main\ndocumentation site references this repository as a content source.\n\n== How the site consumes it\n\n[source,yaml]\n----\ncontent:\n  sources:\n    - url: https://github.com/Payability/automatic-uw-tool.git\n      branches: [release]\n      start_path: docs\n----\n\n`docs/antora.yml` declares the component:\n\n[source,yaml]\n----\nname: automatic-uw-tool\ntitle: Automatic Underwriting Tool\nversion: ~                       # unversioned\nstart_page: ROOT:index.adoc\nnav:\n  - modules/ROOT/nav.adoc\n----\n\n`version: ~` makes the component *unversioned*: the site always shows the state of the branch it\ntracks. If per-branch versions are ever wanted, switch to `version: true` (Antora derives the\nversion from the branch or tag name) — that changes the published URLs, so coordinate with the\nsite owners.\n\nAttributes defined under `asciidoc.attributes` (`{project-name}`, `{repo-url}`, `{python-version}`,\n`{prod-env}`, `{dev-aws-account}`, `{prod-aws-account}`, `{swf-task-list}`, `{coverage-gate}`) are\nsoft-set with `@`, so the playbook can override any of them. Use them instead of repeating values\nthat might change.\n\n== Layout\n\n[source]\n----\ndocs/\n├── antora.yml                     component descriptor\n└── modules/\n    └── ROOT/\n        ├── nav.adoc               the navigation tree (every page must be listed)\n        ├── pages/                 one .adoc per page; subdirectories become URL segments\n        │   ├── index.adoc\n        │   ├── architecture.adoc\n        │   ├── request-lifecycle.adoc\n        │   ├── domain-model.adoc\n        │   ├── processes/\n        │   ├── api/\n        │   ├── events/\n        │   ├── data/\n        │   ├── components/\n        │   ├── operations/\n        │   ├── contributing/\n        │   └── reference/\n        ├── partials/              reusable snippets, included as partial$<name>.adoc\n        ├── examples/              literal payloads, included as example$<name>.json\n        └── attachments/           downloadable files, linked as attachment$<name>\n----\n\n== Conventions\n\n* *One page, one subject.* If a page needs two unrelated headings at level 2, it is probably two\n  pages.\n* *Page header* — a level-0 title plus `:navtitle:` (short, for the sidebar) and `:description:`\n  (one sentence).\n* *Cross-references* use the `xref` macro with the target's path inside `pages/` — for example\n  `+xref:api/tasks.adoc[]+` or `+xref:api/tasks.adoc#execute-task[]+`. Empty brackets take the\n  target's title; add text only when the title does not read well in the sentence.\n* *Anchors* — give a section an explicit `[#kebab-id]` when other pages link to it. The component\n  sets `idprefix: ''` and `idseparator: '-'`, so generated ids are predictable.\n* *Source paths* are written in monospace (`task/process.py`) rather than linked, so they stay\n  valid regardless of branch. The `{repo-tree}` attribute is available if a real link is needed.\n* *Code blocks* are `[source,python]`, `[source,json]`, `[source,bash]`, `[source,yaml]`. Keep\n  quoted code short and current — quote the two or three lines that carry the rule, not whole\n  methods, so the docs do not drift when the file moves.\n* *Reused payloads* live in `examples/` and are included, so the same JSON is never pasted twice.\n* *Admonitions* — `NOTE` for context, `IMPORTANT` for a contract, `WARNING` for a foot-gun. Use\n  them sparingly enough that they still stand out.\n* *Tables* need `options=\"header\"` and explicit `cols=` weights. Escape a literal `|` inside a cell\n  as `\\|`.\n\n== Diagrams\n\nDiagrams are written as Mermaid inside `[source,mermaid]` blocks. Without a diagram extension the\nsite renders them as readable code; if the playbook enables\nhttps://docs.asciidoctor.org/diagram-extension/latest/[asciidoctor-diagram] or\nhttps://github.com/Mogztter/asciidoctor-kroki[asciidoctor-kroki], change the block delimiter to\n`[mermaid]` (or `[mermaid,name,svg]`) to have them rendered as images. Keep the source terse\nenough to read either way.\n\n== Previewing locally\n\nThere is no playbook in this repository, so preview with a throwaway one:\n\n[source,yaml]\n----\n# /tmp/local-playbook.yml\nsite:\n  title: automatic-uw-tool (local)\n  start_page: automatic-uw-tool::index.adoc\ncontent:\n  sources:\n    - url: .                     # run from the repo root\n      branches: HEAD\n      start_path: docs\nui:\n  bundle:\n    url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable\n    snapshot: true\n----\n\n[source,bash]\n----\nnpx antora --fetch /tmp/local-playbook.yml && open build/site/index.html\n----\n\n`branches: HEAD` builds your working branch's committed state. Antora reads from git, so\nuncommitted edits do not appear — commit (or use a local worktree) before previewing.\n\nQuick sanity checks without Node: `asciidoctor -o /dev/null docs/modules/ROOT/pages/**/*.adoc`\ncatches syntax errors, though resource-id resolution (`xref` targets, `partial$` and `example$`\nincludes) is Antora-specific and will warn.\n\n== Adding a page\n\n. Create `docs/modules/ROOT/pages/<area>/<name>.adoc` with a title, `:navtitle:` and\n  `:description:`.\n. Add it to `docs/modules/ROOT/nav.adoc` under the right group — a page missing from the nav is\n  published but unreachable.\n. Link it from the related pages, and from the \"Where to read next\" table in\n  `pages/index.adoc` if it is a major entry point.\n\n== Keeping the docs truthful\n\nUpdating the affected page is part of the change, not a follow-up. The mapping:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Change | Page\n\n| A new or changed process or task | xref:processes/index.adoc[] and the relevant process page, plus xref:domain-model.adoc[] if the framework changed\n| A new or changed HTTP route | xref:api/index.adoc[] and the matching endpoint page\n| A new EventBridge event or SWF change | xref:events/eventbridge-events.adoc[], xref:events/swf-orchestration.adoc[]\n| A new table, attribute or S3 path | xref:data/dynamodb.adoc[], xref:data/s3-datalake.adoc[]\n| A new vendor, AWS or internal client | xref:components/index.adoc[] and a component page\n| A new setting or secret | xref:operations/configuration.adoc[], xref:reference/settings.adoc[]\n| A build or deploy change | xref:operations/deployment.adoc[]\n| A new metric, log or failure mode | xref:operations/observability.adoc[], xref:operations/troubleshooting.adoc[]\n| A new rule for contributors | xref:contributing/python-standards.adoc[]\n| Any new file worth finding | xref:reference/source-map.adoc[]\n| A new term | xref:reference/glossary.adoc[]\n|===\n\nA behaviour change usually touches two or three pages, not one — update the specific page\n*and* the summary table or catalog that mentions it.\n\n`AGENTS.md` at the repository root is the entry point for AI agents and points into this\ncomponent; keep its doc map in step when you add or remove a page.\n\n== The `docs-writer` agent\n\nThis repository ships an AI subagent that owns these pages:\n`.claude/agents/docs-writer.md` (mirrored in `.cursor/agents/`). It is invoked after an\nimplementation lands, reads the diff and the touched source files, edits the right pages,\nand verifies the component still builds warning-free.\n\nTwo things to know if you work with it:\n\n* *It is docs-only.* It never edits application code, tests or config. If the code is wrong,\n  it reports that instead of documenting around it.\n* *It encodes the conventions on this page.* When you change a convention here — a new\n  attribute in `antora.yml`, a different diagram approach, a new page area — update the\n  agent definition too, in both `.claude/agents/` and `.cursor/agents/`, or the two will\n  drift.\n\nThe wider agent chain (`feature-planner` → `plan-reviewer` → `feature-executor` →\n`execution-validator` → `docs-writer`) is described in `AGENTS.md`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"reference/settings.adoc","title":"Settings & secrets reference","description":"Every Dynaconf setting and every secret, with its production and dev value.","url":"https://docx.payability.com/automatic-uw-tool/reference/settings.html","navOrder":37,"source":"= Settings & secrets reference\n:navtitle: Settings & secrets\n:description: Every Dynaconf setting and every secret, with its production and dev value.\n\nGenerated by `settings_setup.py` (non-secret) and `config_setup.py` (secret). Loading model and\ngotchas: xref:operations/configuration.adoc[].\n\n== `default` block\n\nEnvironment-independent values, written for every environment.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Setting | Value\n\n| `ENV_NAME` | The `ENV_NAME` environment variable (also echoed here).\n| `PROJECT_NAME` | `automatic-uw-tool`\n| `SENTRY_DSN` | The shared Sentry DSN.\n| `ENTRUST_API_URL` | `https://api.us.onfido.com/v3.6`\n| `ENTRUST_WORKFLOW_ID` | `41aa2ce5-7c0e-4dbe-91c3-12e058a2d1cf`\n| `THOMSON_REUTERS_PERMISSIBLE_PURPOSE_GLB` | `B`\n| `THOMSON_REUTERS_PERMISSIBLE_PURPOSE_DPPA` | `3`\n| `THOMSON_REUTERS_PERMISSIBLE_PURPOSE_VOTER` | `5`\n| `THOMSON_REUTERS_CERT_FILE_PATH` | `config/thomson_reuters/cert.pem`\n| `THOMSON_REUTERS_KEY_FILE_PATH` | `config/thomson_reuters/key.pem`\n| `THOMSON_REUTERS_BASE64_PFX_FILE_PATH` | `config/thomson_reuters/file.pfx.base64`\n| `THOMSON_REUTERS_PFX_FILE_PATH` | `config/thomson_reuters/main.pfk`\n| `SWF_APP_THREADS` | `5`\n| `SWF_WORKFLOW_TYPE` | `Update Amazon SC Earnings`\n| `SWF_WORKFLOW_TYPE_VERSION` | `1.1`\n| `SWF_AUTOMATIC_UW_TOOL_TASK_LIST` | `{swf-task-list}`\n|===\n\n[NOTE]\n====\n`ENTRUST_API_URL` and `ENTRUST_WORKFLOW_ID` live in `default`, so dev environments talk to the\nsame Onfido workflow as production — only the `ENTRUST_TOKEN` differs (it comes from the\naccount's own secret).\n====\n\n== Per-environment block\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Setting | `{prod-env}` | dev (`<env>`)\n\n| `AUTOMATIC_UW_TOOL_URL`\n| `https://automatic-uw-tool.payability.com`\n| `https://<env>.dev.payability.com/automatic-uw-tool-web-api`\n\n| `THOMSON_REUTERS_API_URL`\n| `https://s2s.thomsonreuters.com/api`\n| `https://s2s.beta.thomsonreuters.com/api`\n\n| `AU10TIX_API_URL`\n| `https://payability.au10tixservices.com/Au10tixBos4`\n| `https://payability.au10tixservicesstaging.com/Au10tixBos4`\n\n| `SWF_DOMAIN`\n| `B2B`\n| `<env>`\n\n| `B2B_TASK_MANAGER_URL`\n| `https://workflow.payability.com/b2btaskmanager`\n| `https://<env>.dev.payability.com/b2btaskmanager`\n\n| `B2B_TASK_MANAGER_GCI`\n| `1`\n| The first six digits of `sha1(<env>)`\n\n| `PYB_DOCUMENTS_URL`\n| `https://pyb-documents.payability.com`\n| `https://<env>.dev.payability.com/pyb-documents`\n\n| `PAYABILITY_INTERNAL_API_URL`\n| `https://api.payability.com/v1/mp`\n| `https://<env>.dev.payability.com/database_v2`\n\n| `S3_DATALAKE_BUCKET`\n| `payability-datalake`\n| `dev-payability-datalake`\n\n| `EVENT_BUS_NAME`\n| `prod_workers`\n| `<env>`\n\n| `EVENT_LOGGER_URL`\n| `https://api.payability.com/eventlogger/`\n| An API Gateway stage URL (`https://drkrpkdxe4.execute-api.us-east-1.amazonaws.com/stage/`)\n\n| `EVENT_LOGGER_INSTANCE_ID`\n| `8001`\n| `8001`\n\n| `SENTRY_ENV`\n| `production`\n| `<env>`\n\n| `SUPPLIER_DATA_VALIDATION_TASK_TABLE_NAME`\n| `automatic-uw-tool-task`\n| `automatic-uw-tool-task-<env>`\n\n| `FINAL_STATUS_TABLE_NAME`\n| `automatic-uw-tool-final-status`\n| `automatic-uw-tool-final-status-<env>`\n\n| `TOOL_TABLE_NAME`\n| `automatic-uw-tool-tool`\n| `automatic-uw-tool-tool-<env>`\n\n| `CHANGELOG_TABLE_NAME`\n| `automatic-uw-tool-changelog`\n| `automatic-uw-tool-changelog-<env>`\n\n| `FAST_API_DOCS_URL`\n| `None`\n| `/docs`\n\n| `FAST_API_REDOC_URL`\n| `None`\n| `/redoc`\n\n| `FAST_API_ROOT_PATH`\n| `/`\n| `/automatic-uw-tool-web-api`\n\n| `FAST_API_CORS_ALLOW_ORIGIN`\n| `.*.payability.com`\n| `.*.dev.payability.com`\n|===\n\n== Secrets\n\nWritten to `config/.secrets.json` by `config_setup.py`, then read as ordinary `settings.*` keys.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Setting | Source | Used by\n\n| everything in the `automatic_uw_tool` secret\n| Secrets Manager `automatic_uw_tool`\n| Copied verbatim — any key added there becomes `settings.<KEY>`.\n\n| `AU10TIX_TOKEN`\n| `prod/au10tix/token` (prod) or `test/au10tix/token` (dev)\n| `Authorization: JWT …` on Au10tix calls.\n\n| `THOMSON_REUTERS_USER`, `THOMSON_REUTERS_PASSWORD`\n| `automatic_uw_tool`\n| HTTP Basic auth on CLEAR calls.\n\n| `PAYABILITY_AUTH_TOKEN`\n| `automatic_uw_tool`\n| `PybApiAuth` in every internal client — exchanged for an `X-Session-Token` carrying `PermCode.INT_LOGIN`.\n\n| `ENTRUST_TOKEN`\n| `automatic_uw_tool`\n| `Authorization: Token token=…` on Onfido calls.\n\n| `ENTRUST_WEBHOOK_TOKEN`\n| `automatic_uw_tool`\n| HMAC key for the `X-SHA2-Signature` on `POST /entrust/webhook`.\n\n| `HUBSPOT_PRIVATE_APP_TOKEN`\n| `automatic_uw_tool`\n| HubSpot Private App token for the `entrust_verification_status` sync.\n\n| `EVENT_LOGGER_KEY`\n| `automatic_uw_tool`\n| Auth key for the internal EventLogger service that supplier events are emitted to.\n|===\n\n[NOTE]\n====\nSupplier-event emission needs all three of `EVENT_LOGGER_URL`, `EVENT_LOGGER_INSTANCE_ID` and\n`EVENT_LOGGER_KEY`. Leaving the URL empty is a supported way to disable emission in an\nenvironment — the client logs a warning once and becomes a permanent no-op for that process.\n====\n\n=== Thomson Reuters certificate material\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Environment | Path\n\n| `{prod-env}`\n| The secret `/thomsonreuters/s2s/production/v3` provides `base64_pfx` and `pfx_pass` → `config/thomson_reuters/file.pfx.base64` and `pfx_password.txt`. The build decodes the PFX and extracts `cert.pem` / `key.pem` with `openssl`.\n\n| dev\n| The secret `thomsonreuters/s2s/test` provides ready-made PEMs under `crt.pem` / `key.pem`, written straight to `config/thomson_reuters/cert.pem` and `key.pem` (escaped newlines restored).\n|===\n\n== Environment variables (not Dynaconf)\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Variable | Purpose\n\n| `ENV_NAME` | Chooses the environment block that `settings_setup.py` writes. Required.\n| `ENV_FOR_DYNACONF` | Chooses the block Dynaconf *reads*. Must equal `ENV_NAME`.\n| `ENV_TYPE` | Build-time only: `dev` selects the dev AWS account in `buildspec.yml`.\n| `GIT_TOKEN` | Installs the private `pyb-sdk` / `eventlogger` dependencies.\n| `AWS_ACCOUNT`, `ROOT_PATH`, `NAME_TAG`, `APP_NAMES` | Build/tagging inputs for `buildspec.yml` and `docker-compose.yml`.\n| `API_HOST`, `API_PORT` | Makefile-only, for `make run-api`.\n|===\n\n== Adding a setting or a secret\n\n. *Non-secret* — add it to `settings_setup.py` (`default` block if it is environment-independent,\n  otherwise both branches), regenerate `config/settings.json`, and document it above.\n. *Secret* — add the key to the `automatic_uw_tool` Secrets Manager entry in *both* AWS accounts.\n  `config_setup.py` copies it through automatically; no code change is needed. Document it above.\n. Read it as `settings.<KEY>` — never `os.getenv`. If a missing value must not crash the process,\n  read it defensively (`getattr(settings, 'KEY', None)`) as the webhook secret does.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"reference/source-map.adoc","title":"Source map","description":"Where to find things in the repository, and which page documents each file.","url":"https://docx.payability.com/automatic-uw-tool/reference/source-map.html","navOrder":38,"source":"= Source map\n:navtitle: Source map\n:description: Where to find things in the repository, and which page documents each file.\n\nA lookup table for \"I need to change X — which file and which page?\".\n\n== Entry points and orientation\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| I need to find… | Go to\n\n| Web API entry point | `web_api_app.py`\n| Worker entry point and main loop | `swf_app.py`\n| The list of processes | `task/process.py`\n| Process → task-name mapping | `task/process_factory.py`\n| Task dispatch and dependency injection | `task/task_factory.py`\n| A specific task's logic | `task/<vendor>/<task_name>/implementation.py`\n| Task base classes and contract | `task/generic_task.py`\n| Failure handling | `utils/task_utils.py`\n| Vendor and AWS clients | `components/`\n| HTTP routes | `interface/web_api/router/`\n| HTTP handlers | `interface/web_api/endpoint_handling/`\n| Runtime configuration object | `config/config.py` (`from config.config import settings`)\n| Non-secret settings generator | `settings_setup.py`\n| Secrets generator | `config_setup.py`\n| Application version | `version.py`\n|===\n\n== File → page\n\n[cols=\"3,3\",options=\"header\"]\n|===\n| Path | Documented in\n\n| `web_api_app.py`, `interface/web_api/router/*`\n| xref:api/index.adoc[]\n\n| `interface/web_api/endpoint_handling/execute_task.py`, `get_task_result.py`, `get_task_status.py`, `delete_task_results.py`, `update_final_status.py`\n| xref:api/tasks.adoc[]\n\n| `interface/web_api/endpoint_handling/get_tools.py`\n| xref:api/tools.adoc[]\n\n| `interface/web_api/endpoint_handling/create_entrust_session.py`, `handle_entrust_webhook.py`\n| xref:api/entrust.adoc[]\n\n| `interface/web_api/schemas.py`, `components/auth/schemas.py`\n| xref:api/schemas.adoc[]\n\n| `interface/web_api/endpoint_handling/base.py`, `web_request_logger.py`\n| xref:request-lifecycle.adoc[], xref:components/auth-permissions.adoc[]\n\n| `swf_app.py`, `components/swf_client.py`, `components/b2b_task_manager.py`\n| xref:events/swf-orchestration.adoc[]\n\n| `components/event_bridge/**`\n| xref:events/eventbridge-events.adoc[]\n\n| `task/process.py`, `task/process_factory.py`, `task/task_factory.py`, `task/generic_task.py`, `task/exceptions/**`, `utils/task_utils.py`\n| xref:domain-model.adoc[]\n\n| `task/au10tix/**`\n| xref:processes/validate-identity-document-au10tix.adoc[]\n\n| `task/entrust/**`\n| xref:processes/validate-identity-document-entrust.adoc[]\n\n| `task/thomson_reuters/request_personal_information_validation/**`, `handle_personal_information_validation_response/**`\n| xref:processes/validate-personal-information.adoc[]\n\n| `task/thomson_reuters/request_personal_background_report/**`, `handle_personal_background_report_response/**`\n| xref:processes/download-personal-background-report.adoc[]\n\n| `task/thomson_reuters/request_business_background_report/**`, `handle_business_background_report_response/**`\n| xref:processes/download-business-background-report.adoc[]\n\n| `components/au10tix/**`\n| xref:components/au10tix.adoc[]\n\n| `components/entrust/client.py`\n| xref:components/entrust.adoc[]\n\n| `components/hubspot/client.py`\n| xref:components/hubspot.adoc[]\n\n| `components/event_logger/client.py`\n| xref:components/event-logger.adoc[]\n\n| `components/thomson_reuters/**`\n| xref:components/thomson-reuters.adoc[]\n\n| `components/aws/**`, `components/task_execution_table.py`, `components/s3_client.py`, `components/s3_purgable/**`\n| xref:components/aws.adoc[], xref:data/dynamodb.adoc[], xref:data/s3-datalake.adoc[]\n\n| `components/payability_internal_api.py`, `components/pyb_documents.py`\n| xref:components/payability-internal.adoc[]\n\n| `components/auth/**`, `components/permissions/**`, `components/process_source.py`\n| xref:components/auth-permissions.adoc[]\n\n| `components/retry/**`, `components/logger.py`, `utils/**`\n| xref:components/utilities.adoc[]\n\n| `config/config.py`, `settings_setup.py`, `config_setup.py`\n| xref:operations/configuration.adoc[], xref:reference/settings.adoc[]\n\n| `buildspec.yml`, `deployment/**`, `artifacts/**`\n| xref:operations/deployment.adoc[]\n\n| `Makefile`, `pyproject.toml`, `requirements.txt`, `pre_requirements.txt`, `uv.lock`\n| xref:operations/local-development.adoc[]\n\n| `pytest.ini`, `development/**`\n| xref:contributing/testing.adoc[]\n\n| `docs/**`\n| xref:contributing/documentation.adoc[]\n|===\n\n== Change recipes\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Task | Touches\n\n| Add a process\n| `task/process.py`, two task packages, `task/process_factory.py`, `task/task_factory.py`, possibly `components/`, `tool_type.py`, `update_final_status.py`, tests, docs. Checklist: xref:domain-model.adoc#adding-a-process[].\n\n| Add an endpoint\n| A handler in `interface/web_api/endpoint_handling/`, a route in the matching router, keep the `JWTAuthorizer` dependency, a pydantic model in `schemas.py`, tests, then xref:api/index.adoc[].\n\n| Add an outbound event\n| A class under `components/event_bridge/events/`, emitted from a handle task, then xref:events/eventbridge-events.adoc[].\n\n| Add a vendor or AWS client\n| A module under `components/`, injected via every `TaskFactory` construction site (`swf_app.py` *and* the Web API handlers), then xref:components/index.adoc[].\n\n| Add a setting or secret\n| `settings_setup.py` or the `automatic_uw_tool` Secrets Manager entry, then xref:reference/settings.adoc[].\n|===\n\n== Repository-root files\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Purpose\n\n| `AGENTS.md` | Operating guide and doc map for AI coding agents; the entry point they read first.\n| `CLAUDE.md` | Points at `AGENTS.md` (single source of truth for agent guidance).\n| `README.md` | Human-facing project overview.\n| `.claude/agents/`, `.cursor/agents/` | Agent definitions (planner, executor, reviewer, validator).\n| `specs/`, `.cursor/plans/` | Historical feature plans — context, not current documentation.\n| `examples/` | Recorded vendor responses used by tests and test mode.\n| `temp/`, `htmlcov/`, `coverage.xml`, `report.xml`, `.coverage` | Generated output; not documentation.\n|===\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"reference/external-apis.adoc","title":"External APIs","description":"The external systems this service depends on, with endpoints, auth and specs.","url":"https://docx.payability.com/automatic-uw-tool/reference/external-apis.html","navOrder":39,"source":"= External APIs\n:navtitle: External APIs\n:description: The external systems this service depends on, with endpoints, auth and specs.\n\n== Vendors\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| System | Auth | Used for\n\n| *Au10tix BOS4* — `AU10TIX_API_URL`\n| `Authorization: JWT \\{AU10TIX_TOKEN}`\n| Identity-document verification: `POST /IdentityDocuments/BeginProcessingRequest`, `POST /IdentityDocuments/CheckReadinessOfResults`, `GET /IdentityDocuments/Results/\\{id}`. See xref:components/au10tix.adoc[].\n\n| *Entrust Identity Verification (Onfido)* — `ENTRUST_API_URL`\n| `Authorization: Token token=\\{ENTRUST_TOKEN}`\n| Applicants, workflow runs, captured artifacts, workflow-run steps. See xref:components/entrust.adoc[].\n\n| *Thomson Reuters CLEAR S2S* — `THOMSON_REUTERS_API_URL`\n| HTTP Basic + mutual TLS client certificate\n| EIDV person search, person/business search and reports (XML). See xref:components/thomson-reuters.adoc[].\n\n| *HubSpot CRM* — `api.hubapi.com` via `hubspot-api-client`\n| Private App token `HUBSPOT_PRIVATE_APP_TOKEN`\n| Contact search by `supplierkey`, PATCH `entrust_verification_status`. See xref:components/hubspot.adoc[].\n|===\n\n== Payability internal systems\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| System | Used for\n\n| *Payability Internal API* — `PAYABILITY_INTERNAL_API_URL`\n| Token validation, user permissions, supplier/owner lookup, and the supplier document store (list, upload, status PATCH). See xref:components/payability-internal.adoc[].\n\n| *PYB Documents* — `PYB_DOCUMENTS_URL`\n| Fetching previously uploaded identity-document images for Au10tix.\n\n| *B2B Task Manager* — `B2B_TASK_MANAGER_URL`\n| Starting SWF workflow executions. See xref:events/swf-orchestration.adoc[].\n|===\n\nAll three authenticate through `PybApiAuth` (`pyb-sdk`), which exchanges\n`PAYABILITY_AUTH_TOKEN` for an `X-Session-Token`.\n\n== AWS services\n\nDynamoDB, S3, SWF, EventBridge and CloudWatch at runtime; Secrets Manager, ECR, CodeBuild and\nCodeDeploy at build/deploy time. See xref:components/aws.adoc[] and\nxref:operations/deployment.adoc[].\n\n== Inbound callers\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Caller | Surface\n\n| Internal admin tooling (`source = internal`)\n| Every `/tasks`, `/tools` and `/entrust/sessions` route.\n\n| Supplier dashboard (`source = supplier_dashboard`)\n| `POST /tasks/execute` for its own supplier, `GET /tools`, `POST /entrust/sessions` for itself.\n\n| Supplier onboarding (`source = onboarding`)\n| `POST /entrust/sessions`.\n\n| Onfido / Entrust\n| `POST /entrust/webhook` (`workflow_run.completed`, HMAC-signed).\n\n| ALB health check\n| `GET /health-check`.\n\n| EventBridge consumers (outside this repository)\n| Subscribe to `pyb.underwriting.events` and `pyb.internal.admin.events` — xref:events/eventbridge-events.adoc[].\n|===\n\n== Vendor specifications\n\nThe Onfido Public API v3.6 OpenAPI document is attached to this component for reference:\n\n* xref:attachment$entrust-api-spec.json[Onfido Public API v3.6 (OpenAPI JSON, ~520 KB)]\n\nIt is a vendor artifact, not generated from this codebase — refresh it from Onfido when their API\nversion changes, and remember that `ENTRUST_API_URL` pins the version segment (`/v3.6`).\n\nAu10tix and Thomson Reuters CLEAR specifications are not vendored here; consult the vendor\nportals. The CLEAR request shapes actually used are the XML templates in\n`components/thomson_reuters/request_template/`.\n"},{"component":"automatic-uw-tool","module":"ROOT","path":"reference/glossary.adoc","title":"Glossary","description":"The vocabulary used across this component and in the code.","url":"https://docx.payability.com/automatic-uw-tool/reference/glossary.html","navOrder":40,"source":"= Glossary\n:navtitle: Glossary\n:description: The vocabulary used across this component and in the code.\n\n[glossary]\nAccess tier:: One of the two verdict tiers of the personal background report — whether the\nsupplier may use the platform. Contrast *advance tier*. See\nxref:processes/download-personal-background-report.adoc[].\n\nAdvance tier:: The second verdict tier of the personal background report — whether the supplier\nmay receive advances.\n\nApplicant:: An Onfido object representing the person being verified. Created from the owner's\nfirst and last name before a *workflow run* can start.\n\nAu10tix:: The vendor performing identity-document verification (BOS4 API). Being replaced by\n*Entrust*. See xref:components/au10tix.adoc[].\n\nB2B Task Manager:: A shared Payability HTTP service that performs\n`StartWorkflowExecution` on SWF. The Web API calls it instead of calling SWF directly.\n\nChangelog:: The append-only DynamoDB table auditing every final-status and tool mutation, stamped\nwith the application version. See xref:data/dynamodb.adoc#changelog-table[].\n\nCLEAR:: Thomson Reuters CLEAR, the vendor behind KYC (EIDV) and the background reports.\n\nCompliance key:: An identifier of a compliance category in Payability's document store. Identity\ndocuments captured through Entrust are uploaded under the KYC identity compliance key.\n\n`correlation_id`:: The single id tying one process execution together — the task row's `task_id`,\nthe final-status partition key, the SWF `task_correlation_id`, and the log prefix. For Entrust it\n*is* the `workflow_run_id`.\n\nDatalake:: The S3 bucket holding raw vendor responses (`payability-datalake`). See\nxref:data/s3-datalake.adoc[].\n\nDynaconf:: The configuration library that assembles `settings` from the generated\n`settings.json` and `.secrets.json`. See xref:operations/configuration.adoc[].\n\nEIDV:: Electronic Identity Verification — the CLEAR product used for the KYC check.\n\nEntrust (Entrust Identity Verification):: Onfido's hosted identity-verification product, the\neventual replacement for Au10tix. Its API *is* the Onfido REST API. See\nxref:components/entrust.adoc[].\n\nFinal status:: The underwriting verdict for one execution, stored per `correlation_id` with full\nversion history and overridable by an underwriter. See xref:data/dynamodb.adoc#final-status-table[].\n\nGCI:: The numeric caller identifier the B2B Task Manager requires (`B2B_TASK_MANAGER_GCI`); `1` in\nproduction, derived from the environment name in dev.\n\nHandle task:: The second, asynchronous step of a process, run by the SWF worker. Also called a\n*handle-response task*.\n\nHosted link:: The Onfido URL a supplier opens to complete verification, with an expiry; reused by\n`POST /entrust/sessions` while it is still valid.\n\nInitial task:: The first, synchronous step of a process, run inside the Web API. Also called the\n*request task*.\n\nLiveness media:: The video or motion capture Onfido records to prove the person is present.\nUploaded to the document store in a second request, after the identity documents.\n\nMotion capture:: Onfido's alternative liveness modality to a live video; a run produces one or the\nother.\n\n`person_id`:: The supplier owner's login key. Combined with `supplier_key` it identifies the\nsubject of a process.\n\nProcess:: A named underwriting workflow with two ordered steps. Four exist. See\nxref:processes/index.adoc[].\n\nProcess source:: Where a request came from — `internal`, `supplier_dashboard` or `onboarding`.\nDrives authorization and rate limits. See xref:components/auth-permissions.adoc[].\n\nPurge:: Deleting the archived S3 results of a process and marking the task rows `purged`, via\n`DELETE /tasks/result`. Implemented by the Thomson Reuters handle tasks only.\n\n`pyb-sdk`:: Payability's internal Python SDK — provides `PybApiAuth` (session-token auth) and the\nCloudWatch metric helpers. Pinned to a Python 3.9-compatible branch, which is why this service\ncannot move to a newer Python.\n\nSection:: One block of a CLEAR personal background report (`ArrestSection`, `CriminalSection`, …),\nscored `PASS` / `FAIL` / `ERROR`.\n\n`supplier_key`:: The Payability supplier identifier — the partition key of the tool and changelog\ntables, and the HubSpot contact's `supplierkey`.\n\nSWF:: AWS Simple Workflow, which runs the asynchronous handle step as an *activity*. See\nxref:events/swf-orchestration.adoc[].\n\nTask:: One executable unit with a unique `task_name`. See xref:domain-model.adoc[].\n\nTask list:: The SWF queue this repository's worker polls: `{swf-task-list}`.\n\nTool:: A verification capability tracked per supplier and person, with a status — what\n`GET /tools` renders as the underwriting checklist. See xref:api/tools.adoc[].\n\nWorkflow run:: An Onfido execution of a Studio workflow for one applicant. Its id is the Entrust\n`correlation_id` and the `TaskExecutionTable` `task_id`.\n"},{"component":"dialer-v2","module":"ROOT","path":"index.adoc","title":"Dialer V2","description":"AI-powered outbound campaign management platform — architecture, business logic, data model, APIs, events and operations.","url":"https://docx.payability.com/dialer-v2/index.html","navOrder":0,"source":"= Dialer V2\n:description: AI-powered outbound campaign management platform — architecture, business logic, data model, APIs, events and operations.\n\n{product-name} is Payability's multi-tenant outbound engagement platform. It runs AI voice\ncampaigns, email campaigns and multi-channel nurture sequences against a shared contact\ngraph, analyses every conversation with an LLM, and pushes qualified outcomes into HubSpot.\n\nThis documentation component describes the whole system: the business rules it enforces,\nthe architecture that enforces them, the data it stores, the HTTP surface it exposes, the\nevents it consumes, the durable workflows it runs, and how to operate it.\n\n== What the platform does\n\n[cols=\"1,3a\",options=\"header\"]\n|===\n| Capability | Summary\n\n| Voice campaigns\n| Import phone targets, dial them through Retell AI on a per-target state machine with\ncall windows, concurrency limits, retry cool-offs and a deterministic \"double tap\".\nSee xref:domain:call-campaigns.adoc[] and xref:domain:target-lifecycle.adoc[].\n\n| Conversation intelligence\n| Every eligible call is scored by an LLM against the caller agent's own prompt, producing\na disposition verdict, an agent score and a list of issues. Analyses can be re-run in bulk.\nSee xref:domain:call-analysis.adoc[].\n\n| AI agent management\n| Caller and analysis agents are versioned in the database and synced to Retell AI, with\nregression test cases and AI-assisted prompt suggestions. See xref:domain:ai-agents.adoc[].\n\n| Email campaigns\n| Resend-backed sending with content variations, per-variation hourly rates, daily caps per\naccount and per domain, RFC 8058 unsubscribe, and full webhook-driven delivery tracking.\nSee xref:domain:email-campaigns.adoc[] and xref:domain:email-deliverability.adoc[].\n\n| Nurture sequences\n| Templated multi-step journeys (email, SMS, call task, wait, BigQuery data gate, CRM task,\nCRM event) executed as a single durable Inngest run per enrolled contact.\nSee xref:domain:sequences.adoc[].\n\n| Contact graph\n| A per-workspace `Contact` record with phone and email registries that carry carrier data,\nrisk levels, block flags and opt-out state. See xref:domain:contacts.adoc[].\n\n| Human calling\n| Outcomes from reps dialing in Nooks are ingested, attributed to a `SalesRep`, analysed, and\ncan close deals lost in HubSpot. See xref:events:nooks.adoc[].\n\n| Reporting\n| Daily call stats, funnel, Sankey disposition flow, per-agent performance, phone-number\npickup rate, and email campaign reports. See xref:domain:reporting.adoc[].\n\n| Tool server\n| A Model Context Protocol endpoint exposes call history, outbound calling, SMS and email as\ntools for external AI orchestrators. See xref:api:mcp.adoc[].\n|===\n\n== How to read this component\n\n[cols=\"1,2a\",options=\"header\"]\n|===\n| Module | Read it when you need to…\n\n| xref:architecture:index.adoc[Architecture]\n| understand how the pieces fit together, where logic is allowed to live, how tenancy is\nenforced, and how asynchronous work is made durable.\n\n| xref:domain:index.adoc[Business logic]\n| understand *what the product does and why* — state machines, eligibility rules, caps,\nretries, suppression and scoring.\n\n| xref:data:index.adoc[Data model]\n| understand the Prisma schema: models, enums, relations, indexes and invariants.\n\n| xref:api:index.adoc[API reference]\n| call or change an HTTP endpoint.\n\n| xref:events:index.adoc[Events]\n| understand inbound webhooks, the dispatcher registries and the handlers they route to.\n\n| xref:workflows:index.adoc[Workflows]\n| understand the durable Inngest functions and their steps, retries and guards.\n\n| xref:integrations:index.adoc[Integrations]\n| work with Retell AI, Resend, HubSpot, Nooks, Supabase, BigQuery, SQS, Redis, the AI\nGateway or Sentry.\n\n| xref:security:index.adoc[Security]\n| understand authentication, workspace authorization, machine-to-machine secrets and\nwebhook verification.\n\n| xref:frontend:index.adoc[Frontend]\n| work on the dashboard: routes, React Query data layer, component conventions, theming.\n\n| xref:ops:index.adoc[Operations]\n| set up a local environment, configure variables, deploy, or debug a live incident.\n|===\n\n== Orientation for new engineers\n\n. xref:architecture:index.adoc[] then xref:architecture:layers.adoc[] — the three-layer rule\n  (routes orchestrate, services decide, repositories touch the database) is the single most\n  load-bearing convention in the codebase.\n. xref:domain:call-campaigns.adoc[] and xref:domain:target-lifecycle.adoc[] — the dialer is\n  the oldest and most intricate subsystem.\n. xref:architecture:multi-tenancy.adoc[] — every query is scoped by `subscriptionId`; getting\n  this wrong leaks data across workspaces.\n. xref:ops:local-development.adoc[] — get it running.\n"},{"component":"dialer-v2","module":"ROOT","path":"tech-stack.adoc","title":"Technology stack","description":"Runtime, framework, data and third-party services that make up Dialer V2.","url":"https://docx.payability.com/dialer-v2/tech-stack.html","navOrder":1,"source":"= Technology stack\n:description: Runtime, framework, data and third-party services that make up Dialer V2.\n\n== Runtime and tooling\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Concern | Choice | Notes\n\n| Node runtime | Node 20 | Pinned in `.nvmrc`\n| Package manager | pnpm | `.npmrc` configures the store\n| Language | TypeScript 5 | `strict` mode; no `any` in new code\n| Framework | Next.js 16 (App Router) | Server components by default, `\"use client\"` opt-in\n| UI runtime | React 19 |\n| Build | `next build` | `pnpm build:prod` runs `prisma generate` first\n| Hosting | Vercel | No Vercel crons; scheduled jobs are Inngest cron functions\n|===\n\n== Frontend\n\n* *Tailwind CSS 4* with `@tailwindcss/postcss`; tokens defined in `app/globals.css`.\n* *shadcn/ui* on top of Radix primitives — `components/ui/` is generated and must not be\n  edited directly (extend by composition).\n* *TanStack React Query v5* for all server state; there is no Redux/Zustand layer.\n* *React Hook Form* + `zodResolver` for every form.\n* *Recharts* for reporting charts, *Mermaid* for in-app diagrams, *Lexical* for the rich-text\n  email editor, *xlsx* for spreadsheet import/export.\n* *next-themes* for light/dark switching.\n\n== Backend\n\n* *Next.js route handlers* under `app/api/` are the only HTTP surface.\n* *Prisma 6* is the sole database client, generated into `prisma/generated`.\n* *Zod 3* validates every inbound payload and every outbound response consumed by a hook.\n* *Inngest* provides durable, retryable workflows (`app/api/inngest/route.ts` serves them).\n* *Redis* (`redis` client) holds the in-flight call counter used to throttle broadcasts.\n* *AWS SQS* carries CRM and email-categorisation messages to downstream services.\n\n== Data\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Store | Role\n\n| Supabase PostgreSQL | System of record for every model in `{prisma-schema}`\n| Supabase Auth | User identity, sessions, HTTP-only session cookies\n| Supabase Realtime | Live campaign/broadcast notifications to the dashboard\n| Supabase Edge Functions | `lib/supabase/functions/` — event logger and campaign scheduler helpers\n| Redis | Ephemeral counters (in-flight calls per campaign)\n| Google BigQuery | Read-only mirror of CRM/engagement data, queried by sequence data gates\n|===\n\n== Third-party services\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Service | Used for | Reference\n\n| Retell AI | AI voice calls, SMS chats, agent configuration | xref:integrations:retell.adoc[]\n| Resend | Campaign and transactional email, delivery webhooks, topics | xref:integrations:resend.adoc[]\n| HubSpot | CRM contacts, engagements, tasks, deal stages | xref:integrations:hubspot.adoc[]\n| Nooks | Human-dialer call outcomes | xref:integrations:telephony.adoc[]\n| Twilio / Plivo | Carrier numbers imported into Retell via SIP trunks | xref:integrations:telephony.adoc[]\n| MessagingApp | Internal microservice that purchases and releases numbers | xref:integrations:telephony.adoc[]\n| Vercel AI Gateway | Single endpoint fronting OpenAI, Anthropic, xAI and Google models | xref:integrations:platform-services.adoc#ai-gateway[AI Gateway]\n| Sentry | Error and performance monitoring across client, server and edge | xref:integrations:platform-services.adoc#sentry[Sentry]\n|===\n\n== Documentation tooling\n\nThe in-app documentation site at `/docs` is built with *Fumadocs* (`fumadocs-mdx`,\n`fumadocs-ui`) from the MDX files in `docs/`. This Antora component lives alongside it under\n`docs/modules/` and is consumed by an external Antora playbook — see\nxref:doc-conventions.adoc[].\n"},{"component":"dialer-v2","module":"ROOT","path":"repository-map.adoc","title":"Repository map","description":"Where everything lives in the Dialer V2 repository.","url":"https://docx.payability.com/dialer-v2/repository-map.html","navOrder":2,"source":"= Repository map\n:description: Where everything lives in the Dialer V2 repository.\n\n== Top level\n\n[source]\n----\ndialer-v2/\n├── app/                 Next.js App Router — pages, route handlers, docs site\n├── components/          React components, grouped by feature\n├── lib/                 All non-UI code: services, repositories, workflows, events, schemas\n├── prisma/              Prisma schema, migrations, generated client, one-off SQL\n├── scripts/             Operational scripts and Retell agent definitions\n├── specs/               Historical feature plans (design records, not living docs)\n├── docs/                Documentation: Fumadocs MDX + this Antora component\n├── types/               Ambient TypeScript declarations\n├── middleware.ts        Route protection and email-domain gating\n└── vercel.firewall.json Vercel WAF custom rules (applied with pnpm firewall:apply)\n----\n\n== `app/`\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Path | Contents\n\n| `app/(auth)/` | `login`, `signup` — unauthenticated shell\n| `app/(dashboard)/` | Every authenticated page; see xref:frontend:routes.adoc[]\n| `app/api/` | All route handlers; see xref:api:index.adoc[]\n| `app/docs/` | Fumadocs renderer for the MDX docs\n| `app/globals.css` | Theme tokens for light and dark canvases\n|===\n\n== `lib/`\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Path | Contents\n\n| `lib/ai/` | `BaseAIService` and concrete services: call analysis, coverage recommendation, email variation, prompt suggestion, test-case runner\n| `lib/ai-agents/` | Shared AI agent types\n| `lib/auth/` | `resolveSubscription`, internal-secret verification, allowed-email-domain gate, password strength\n| `lib/bigquery/` | BigQuery client and data-gate query builder\n| `lib/events/` | Webhook dispatchers and handlers (Retell, Resend, Nooks)\n| `lib/hooks/` | React Query hooks, one file per resource\n| `lib/mcp/` | MCP JSON-RPC server, tool registry and tool implementations\n| `lib/phone-service/` | Phone-number purchase/release adapters (MessagingApp, Retell)\n| `lib/plugins/` | Shared infrastructure clients: Inngest, Redis, Resend, SQS, HubSpot\n| `lib/redis/` | Call-target in-flight counter\n| `lib/repositories/` | *The only layer allowed to use Prisma*\n| `lib/retellai/` | Retell client wrapper, agent config, post-call analysis schema\n| `lib/schema/` | Zod schemas, one file per feature\n| `lib/sequences/` | CRM event registry and template-variable interpolation\n| `lib/serializers/` | Prisma → API response mappers\n| `lib/services/` | Business logic: calling, phone selection, email campaigns, sequences, Retell agents, coverage\n| `lib/supabase/` | Server/browser/admin/route-handler clients and edge functions\n| `lib/types/` | Domain types not derived from Zod\n| `lib/utils/` | Pure helpers (timezones, area codes, masking, mapping, HubSpot signatures)\n| `lib/workflows/` | Inngest durable functions\n| `lib/constants.ts` | App-wide constants, disposition sets, provider registry, secrets\n| `lib/enums.ts` | `const enum` values for states, event types and skip reasons\n| `lib/types.ts` | Public TypeScript types, all via `z.infer<>`\n|===\n\n== `prisma/`\n\n* `schema.prisma` — every model and enum (see xref:data:index.adoc[]).\n* `migrations/` — ordered SQL migrations; `migration_lock.toml` pins the provider.\n* `generated/` — generated Prisma client (checked in; regenerate with `pnpm prisma generate`).\n* `sql/` — one-off backfills and an auth hook, run manually against the database. These are\n  historical and are *not* part of the migration chain; see xref:data:index.adoc#one-off-sql[One-off SQL].\n\n== `scripts/`\n\n* `scripts/retelai/` — Retell agent definitions (`agents/<name>/agent.md` plus `index.ts`),\n  a `registry.ts` that is the single source of the fallback agent id/name list, and `sync.ts`\n  to push definitions to Retell.\n* Ad-hoc maintenance scripts (`backfill_email_click.ts`, `fix-hubspot-deal-to-contact.ts`,\n  `test-bigquery.ts`, `retell_remove_numbers.ts`).\n\n== `specs/`\n\nPoint-in-time implementation plans, named `<feature>_<hash>.plan.md`. They record *why* a\nfeature was built a particular way and are useful archaeology, but they are not kept in sync\nwith the code — this documentation component is the current source of truth.\n"},{"component":"dialer-v2","module":"ROOT","path":"glossary.adoc","title":"Glossary","description":"Domain vocabulary used throughout Dialer V2.","url":"https://docx.payability.com/dialer-v2/glossary.html","navOrder":3,"source":"= Glossary\n:description: Domain vocabulary used throughout Dialer V2.\n\n[glossary]\nWorkspace / Subscription::\nThe tenant boundary. Modelled as `Subscription`; every tenant-scoped row carries a\n`subscriptionId`. Users join workspaces through `SubscriptionUser` with a role of `owner`,\n`admin` or `member`. \"Workspace\" is the UI term, \"subscription\" the database term.\n\nProfile::\nThe application-side record of a Supabase user (`Profile.supabaseUserId`). Distinct from\n`SalesRep`, which is a workspace-scoped CRM identity that may exist before any login.\n\nCampaign::\nA run of outbound activity. `Campaign.type` is `call` (dialer) or `email`. Both types share\nthe same table and status enum; only the configuration columns differ.\n\nCampaign target::\nOne phone number enrolled in a call campaign (`CampaignTarget`), carrying its own state\nmachine, attempt count and schedule. Email recipients are a separate model\n(`EmailCampaignTarget`).\n\nBroadcast::\nOne tick of a campaign's send/dial loop. Triggered by `pg_cron` calling\n`+POST /api/campaigns/{id}/broadcast+` or `+POST /api/email-campaigns/{id}/broadcast+`. A\nbroadcast leases a batch of due targets and enqueues one Inngest event per target.\n\nLease::\nA short-lived claim on a target so two concurrent broadcasts cannot dial or email it twice.\nCall targets use `leaseExpiresAt`; email targets use `queuedAt` with a lock window of 2 days (`EMAIL_TARGET_LOCK_WINDOW_SECONDS`).\n\nDouble tap::\nA deterministic same-day retry. When a call ends with a reason in `DOUBLE_TAP_REASONS`\n(voicemail, machine detected, no answer, declined), the `callEndedAnalysis` workflow sleeps\none minute and re-fires the dial event for the same target — subject to a\none-call-today-per-number guard and the campaign's `maxAttempts`.\n\nCooling off::\nThe standard retry delay for all other recoverable outcomes:\n`nextEarliestAttemptAt = now + waitHoursToRecall` (24 hours by default).\n\nDisposition::\nThe business outcome of a call. `retellDisposition` is the raw provider value; `disposition`\nis the platform's interpretation. Values are enumerated by `RetellDisposition` in\n`lib/constants.ts` and drive HubSpot eligibility and retry rules.\n\nValid conversation::\nA call lasting at least `RETRY_CONSTANTS.MIN_VALID_CONVERSATION_SECONDS` (10 s) that was not\nvoicemail and not screened. Used as the funnel's first qualification step.\n\nCaller agent::\nAn `AiAgent` with `type = caller`, versioned via `AiAgentVersion` and mirrored to Retell AI\n(`retellAgentId`). Its `mainPrompt` is the call script and is also fed to the analysis LLM as\ncontext.\n\nAnalysis agent::\nAn `AiAgent` with `type = analysis`. Its prompt supplies the criteria used to grade calls; the\nresult is stored in `CallAiAgentAnalysisResult`.\n\nVariation::\nAn `EmailVariation` — a subject plus HTML body for an email campaign. A variation with\n`campaignId = NULL` is a reusable *template* and is never sent directly.\n\nSending domain / email account::\n`Domain` is the Resend-verified domain and owns a shared daily cap. `EmailSendingDomain` is a\nconcrete `From` address under that domain with its own cap and counters. The UI calls the\nlatter an \"email account\".\n\nRegistry::\nThe per-workspace canonical lists of contactable endpoints: `TargetPhoneNumbers` for phone\nnumbers and `TargetEmails` for addresses. They hold carrier data, risk level, block flags and\nopt-out state, and gate whether an endpoint may be contacted at all.\n\nSuppression::\nA registry-level block on contacting an address. An address is suppressed when its\n`TargetEmails` row has any of `newsletterOptOut`, `marketingOptOut` or `isBlocked`.\n\nSequence template::\nA `SequenceTemplate` plus its ordered `SequenceTemplateStep` rows: the reusable definition of\na nurture journey.\n\nEnrollment::\nA `ContactSequence` — one contact travelling through one template. Driven end-to-end by a\nsingle durable Inngest run whose id is stamped as `executionId`.\n\nData gate::\nA sequence step (`channel = data_gate`) that evaluates conditions against a BigQuery view for\nthe contact and either branches, or waits and re-checks up to `gateMaxRetries` times.\n\nCRM event::\nA sequence step (`channel = crm_event`) that publishes a user-defined, template-interpolated\nJSON body to the CRM SQS queue with an `event_type` chosen from a fixed registry.\n\nFollow-up task::\nA `FollowUpTask` assigned to a `SalesRep`, optionally mirrored into HubSpot and/or Nooks, with\nan `escalationStage` that advances when it is not actioned.\n\nActivity::\nA unified interaction log (`Activity`) covering SMS, email, calls, tasks, meetings, notes and\nreminders. The activity feed merges these with `Call` rows.\n\nNooks::\nThe third-party dialer used by human reps. Its `call.logged` webhook produces `Call` rows with\n`channelType = \"nooks_call\"` and no campaign or target.\n\nMCP::\nModel Context Protocol. `POST /api/mcp` exposes a JSON-RPC 2.0 tool server so external AI\norchestrators can list agents, read call history, initiate calls, send SMS and send email.\n"},{"component":"dialer-v2","module":"ROOT","path":"doc-conventions.adoc","title":"About this documentation","description":"How this Antora component is structured, built and extended.","url":"https://docx.payability.com/dialer-v2/doc-conventions.html","navOrder":4,"source":"= About this documentation\n:description: How this Antora component is structured, built and extended.\n\nThis component (`dialer-v2`) is not a standalone Antora site. It is designed to be added as a\n*content source* to a larger Antora playbook that aggregates documentation from several\nPayability repositories.\n\n== Consuming this component\n\nAdd the repository as a content source and point `start_path` at `docs`:\n\n[source,yaml]\n----\ncontent:\n  sources:\n    - url: https://github.com/<org>/dialer-v2.git\n      branches: [main]\n      start_path: docs\n----\n\nThe component descriptor is `docs/antora.yml`. It declares:\n\n* `name: dialer-v2`, `title: Dialer V2`\n* `version: ~` — the component is *unversioned*, so its URLs carry no version segment.\n  If the aggregating playbook needs branch-based versions, change this to `version: true`\n  (derive from the branch) or a literal such as `version: '1.0'`.\n* `start_page: ROOT:index.adoc`\n* An explicit `nav` list, one entry per module.\n\n== Module layout\n\nOne module per concern, so that cross-references read as `+xref:MODULE:PAGE.adoc[]+`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Module | Scope\n\n| `ROOT` | Landing page, stack, repository map, glossary, these conventions\n| `architecture` | Structure, layering, tenancy, async runtime, cross-cutting concerns\n| `domain` | Business logic and rules, per capability\n| `data` | Prisma models, enums, relations, indexes\n| `api` | HTTP reference, grouped by resource\n| `events` | Inbound webhooks and their handler registries\n| `workflows` | Durable Inngest functions\n| `integrations` | Third-party systems and how they are wired\n| `security` | Authentication, authorization, secrets, webhook verification\n| `frontend` | Dashboard routes, data layer, UI conventions\n| `ops` | Environment, local setup, deployment, scheduled jobs, troubleshooting\n|===\n\n== Writing conventions\n\n* One `=` level-0 title per page, plus a `:description:` attribute — the aggregating site uses\n  it for search snippets and social previews.\n* Code paths are written in monospace relative to the repository root, e.g.\n  `lib/services/phone-number-calling-service.ts`. Do not use absolute paths.\n* API endpoints are written as `METHOD /api/path` with `+{id}+` for path parameters.\n* Field names use the casing of the layer being described: Prisma models are `camelCase`,\n  API request/response bodies are `snake_case`. Say which layer you mean.\n* Prefer tables for enumerations of fields, statuses and endpoints; prefer prose for rules\n  and rationale.\n* Record *why* a rule exists when the reason is not obvious from the rule itself. That is the\n  part the code cannot express.\n\n== Diagrams\n\nDiagrams are written as Mermaid inside a literal block with a `[mermaid]` style:\n\n[source]\n----\n[mermaid]\n....\nflowchart TD\n  A --> B\n....\n----\n\nWithout a diagram extension this renders as readable Mermaid source. To render it as an image,\nadd https://github.com/asciidoctor/asciidoctor-kroki[asciidoctor-kroki] to the playbook:\n\n[source,yaml]\n----\nasciidoc:\n  extensions:\n    - asciidoctor-kroki\n  attributes:\n    kroki-fetch-diagram: true\n----\n\n== Relationship to the in-app docs site\n\nThe repository also serves a Fumadocs site at `/docs`, built from the `.mdx` files that sit\ndirectly under `docs/`. Those files are the origin of much of this component's content.\nAntora ignores them (it only reads `docs/modules/*/pages`), so the two can coexist, but they\nare *two sources of truth*. When the Fumadocs site is retired, delete the `.mdx` files,\n`source.config.ts`, `lib/source.ts` and `app/docs/`, and this component becomes the only one.\n\n== Keeping it current\n\nUpdate this component in the same change that alters behaviour:\n\n* schema change → `data` module and the relevant `domain` page\n* new or changed endpoint → `api` module\n* new webhook event type → `events` module\n* new Inngest function or step → `workflows` module\n* new environment variable → xref:ops:environment-variables.adoc[]\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:index.adoc","title":"Architecture overview","description":"System context, containers and the shape of the Dialer V2 codebase.","url":"https://docx.payability.com/dialer-v2/architecture/index.html","navOrder":5,"source":"= Architecture overview\n:description: System context, containers and the shape of the Dialer V2 codebase.\n\n{product-name} is a single Next.js application. There is no separate backend service: HTTP\nroute handlers, business logic, database access and durable workflow definitions all live in\nthe same deployment, with long-running or retryable work delegated to Inngest and downstream\nqueues.\n\n== System context\n\n[mermaid]\n....\nflowchart LR\n  User[\"Operator (browser)\"] --> App\n\n  subgraph App[\"Dialer V2 — Next.js on Vercel\"]\n    Pages[\"Dashboard pages\"]\n    Api[\"Route handlers /api/*\"]\n    Inngest[\"Inngest functions\"]\n    Mcp[\"MCP tool server\"]\n  end\n\n  App --> PG[(\"Supabase PostgreSQL\")]\n  App --> Auth[\"Supabase Auth\"]\n  App --> Redis[(\"Redis\")]\n  App --> BQ[(\"BigQuery\")]\n  App --> SQS[\"AWS SQS\"]\n  App --> Gateway[\"Vercel AI Gateway\"]\n\n  App --> Retell[\"Retell AI\"]\n  App --> Resend[\"Resend\"]\n  App --> HubSpot[\"HubSpot\"]\n  App --> MsgApp[\"MessagingApp\\n(number purchase)\"]\n\n  Retell -- webhooks --> Api\n  Resend -- webhooks --> Api\n  HubSpot -- webhooks --> Api\n  Nooks[\"Nooks (human dialer)\"] -- webhooks --> Api\n  Cron[\"pg_cron / Inngest cron\"] --> Api\n  Orchestrators[\"External AI orchestrators\"] --> Mcp\n....\n\n== Containers and responsibilities\n\n[cols=\"1,2a\",options=\"header\"]\n|===\n| Container | Responsibility\n\n| Dashboard (React Server + Client Components)\n| Renders the operator UI. All server state flows through React Query hooks that call the\napp's own HTTP API — pages never query Prisma directly. See xref:frontend:index.adoc[].\n\n| Route handlers (`app/api/**/route.ts`)\n| The only HTTP surface. Authenticate, validate, delegate, serialise. No business logic, no\nPrisma. See xref:api:index.adoc[].\n\n| Services (`lib/services`, `lib/ai`)\n| All business decisions: eligibility, selection, caps, scoring, state transitions.\n\n| Repositories (`lib/repositories`)\n| The only code allowed to import Prisma. Own every query, index usage and transaction.\n\n| Event handlers (`lib/events`)\n| Turn provider webhooks into database changes and follow-up events, through per-provider\ndispatcher registries. See xref:events:index.adoc[].\n\n| Inngest functions (`lib/workflows`)\n| Durable, step-wise, retryable work: dialing, post-call analysis, email sending, sequence\nexecution, number purchase. See xref:workflows:index.adoc[].\n\n| MCP server (`lib/mcp`)\n| A thin JSON-RPC 2.0 tool layer over the same services, for external AI orchestrators.\n|===\n\n== Guiding decisions\n\nThree-layer discipline::\nRoutes orchestrate HTTP, services decide, repositories persist. This is enforced by\nconvention and review rather than tooling, and it is what makes the dialer's rules testable\nin isolation. See xref:layers.adoc[].\n\nTenant scoping is not optional::\nEvery tenant-scoped query filters on `subscriptionId`, resolved once per request by\n`resolveSubscription()`. See xref:multi-tenancy.adoc[].\n\nWebhooks acknowledge fast, work later::\nAn inbound webhook persists the raw payload, updates the minimum state needed, and emits an\nInngest event for anything expensive. This keeps provider retries rare and makes the heavy\nwork replayable. See xref:async-runtime.adoc[].\n\nRegistries pattern for extension points::\nWebhook event types, MCP tools, CRM event types and carrier providers are all registered in a\nsingle map so that adding one is a one-file change. See\nxref:events:extending.adoc[] and xref:api:mcp.adoc[].\n\nSchema-first types::\nA Zod schema is the source of truth; TypeScript types are `z.infer<>` of it, and API responses\nare parsed through the schema in the React Query hook so schema drift surfaces immediately.\n\nIdempotency by construction::\n`WebhookEvent` is unique on `(source, eventId)`; `Call.providerCallId` is unique; the\n`emailSend` workflow refuses to re-send a target that has left `pending`. Replays are safe.\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:layers.adoc","title":"Layering rules","description":"The three-layer rule, error taxonomy and where each kind of logic belongs.","url":"https://docx.payability.com/dialer-v2/architecture/layers.html","navOrder":6,"source":"= Layering rules\n:description: The three-layer rule, error taxonomy and where each kind of logic belongs.\n\n== The rule\n\n[source]\n----\nHTTP            app/api/campaigns/[id]/broadcast/route.ts\n  │  authenticate → validate → delegate → serialise\n  ▼\nService         lib/services/phone-number-calling-service.ts\n  │  decide: eligibility, selection, caps, state transitions\n  ▼\nRepository      lib/repositories/call-repository.ts\n  │  the only layer that imports Prisma\n  ▼\nPostgreSQL\n----\n\n*Never* put business logic in a route handler, and *never* import Prisma outside\n`lib/repositories`. Two exceptions exist in the current codebase and are deliberate:\n\n* Webhook receivers upsert the raw `WebhookEvent` row directly, because that write is part of\n  accepting the request rather than part of the domain.\n* A small number of routes read a single lookup row (for example resolving a campaign's\n  `subscriptionId` before delegating). New code should prefer a repository method.\n\n== What belongs in each layer\n\n[cols=\"1,2a\",options=\"header\"]\n|===\n| Layer | Belongs here\n\n| Route handler\n|\n* `resolveSubscription()` or a Supabase `getUser()` check\n* Zod parsing of body and query parameters\n* Mapping domain results to `snake_case` JSON\n* HTTP status selection and the `{ error: string }` envelope\n* Nothing that would need to be re-implemented for a non-HTTP caller\n\n| Service\n|\n* Eligibility and guard evaluation (DNC, call windows, caps, suppression)\n* Selection algorithms (which phone number, which agent, which variation)\n* State-machine transitions and next-attempt computation\n* Calls to third-party clients (Retell, Resend, HubSpot, SQS)\n* Emitting Inngest events\n\n| Repository\n|\n* Every `prisma.*` call\n* `where` builders shared between a list query and its count query\n* Transactions, including the atomic \"select and lease\" patterns\n* Aggregations (`groupBy`, `count`) used for stats\n|===\n\n== Canonical route handler\n\n[source,typescript]\n----\nimport { NextResponse } from \"next/server\"\nimport { resolveSubscription } from \"@/lib/auth/subscription\"\n\nexport async function GET(request: Request) {\n  const result = await resolveSubscription()\n  if (!result.success) {\n    return NextResponse.json({ error: result.error }, { status: result.status })\n  }\n  const { subscriptionId } = result.context\n\n  try {\n    // delegate to a service or repository, then map camelCase → snake_case\n    return NextResponse.json(payload)\n  } catch (error) {\n    console.error(\"Operation failed\", error)\n    return NextResponse.json({ error: \"Unable to complete operation\" }, { status: 500 })\n  }\n}\n----\n\nStatus codes: `200` success, `201` created, `400` validation, `401` unauthenticated,\n`403` not a member of the workspace, `404` not found, `500` unexpected.\n\n== Serialisation boundary\n\nPrisma models are `camelCase`; the HTTP API is `snake_case`. The conversion happens in the\nroute handler or in a dedicated serialiser under `lib/serializers/`\n(`email-domain.ts`, `email-variation.ts`). Client-side TypeScript is `camelCase` again,\nderived from the Zod schema that parses the response — so the `snake_case` shape exists only\non the wire.\n\n== Error taxonomy\n\nCalling errors are typed in `lib/utils/calling-errors.ts` so that a failure can be attributed\nto a layer without string matching:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Error | Raised when\n\n| `CallInitiationError` | Base class; carries `code`, `targetId`, `originalError`\n| `PhoneNumberSelectionError` | No eligible source number, or validation of one failed\n| `RetellCallError` | The Retell API rejected or failed the call creation\n| `DatabaseError` | A persistence step failed after the external side effect\n|===\n\nSkip reasons — the non-error outcomes of a dial attempt — are enumerated by `CallSkipReason`\nin `lib/enums.ts`: `prior_disposition`, `warm_lead`, `lifecycle_stage`, `dnc`,\n`campaign_not_running`, `invalid_e164`, `target_not_found`, `no_phone_number_available`,\n`outside_call_window`, `retell_error`. Workflows return the reason rather than throwing, so an\nineligible target does not consume a retry.\n\n== Validation and types\n\n[source,typescript]\n----\n// lib/schema/reports.ts — the schema is the source of truth\nexport const DailyStatSchema = z.object({ /* … */ })\n\n// lib/types.ts — the type is derived, never hand-written\nexport type DailyStat = z.infer<typeof DailyStatSchema>\n----\n\nEvery React Query hook parses its response through the schema. A backend change that drops or\nrenames a field therefore fails loudly in the client instead of silently rendering `undefined`.\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:request-lifecycle.adoc","title":"Request lifecycle","description":"What happens between a browser request and a database write.","url":"https://docx.payability.com/dialer-v2/architecture/request-lifecycle.html","navOrder":7,"source":"= Request lifecycle\n:description: What happens between a browser request and a database write.\n\n== Page request\n\n. `middleware.ts` runs for every non-excluded path. It short-circuits `/.well-known`,\n  `/icons/*` and `/assets/*`, then reads the Supabase session from cookies.\n. If a session exists, the user's email is checked against\n  `isAllowedPayabilityEmail()` — anything outside `payability.com` (or a subdomain) is signed\n  out and redirected to `/login?error=forbidden_domain`.\n. Unauthenticated requests to anything other than `/login`, `/signup`, `/auth/callback`,\n  `/docs` or `/api` are redirected to `/login?redirectedFrom=<path>`.\n. Authenticated requests to `/login` or `/signup` are redirected to `/`.\n. The dashboard layout renders inside *WorkspaceGate*, which blocks rendering until\n  `GET /api/me` resolves and the user has at least one workspace with an active selection.\n\nExcluded from middleware entirely (see the `matcher` in `middleware.ts`): `_next/static`,\n`_next/image`, `favicon.ico`, `icons`, `assets`, `images`, `fonts`, `supabase/functions`,\n`api/webhooks`, `.well-known`.\n\n== API request\n\n[mermaid]\n....\nsequenceDiagram\n  participant C as Client (React Query hook)\n  participant R as Route handler\n  participant A as resolveSubscription()\n  participant S as Service\n  participant D as Repository\n  participant P as PostgreSQL\n\n  C->>R: fetch(\"/api/…\", { cache: \"no-store\" })\n  R->>A: resolve user + active workspace\n  A->>P: Profile + SubscriptionUser lookup\n  A-->>R: { userId, subscriptionId }\n  R->>R: Zod parse body / query\n  R->>S: delegate\n  S->>D: repository call\n  D->>P: Prisma query (scoped by subscriptionId)\n  P-->>D: rows\n  D-->>S: models\n  S-->>R: domain result\n  R-->>C: snake_case JSON\n  C->>C: Schema.parse(response)\n....\n\n`resolveSubscription()` also stamps Sentry with the user id, email and `subscriptionId` tag,\nso any error captured later in the request is already attributed to a tenant.\n\n== Webhook request\n\nProvider webhooks bypass middleware (`api/webhooks` is excluded from the matcher) and\nauthenticate themselves:\n\n. Read the raw body as text — signature verification needs the exact bytes.\n. Verify the provider signature where one exists (Svix for Resend, HMAC for HubSpot, optional\n  bearer/`X-Nooks-Signature` for Nooks).\n. Upsert a `WebhookEvent` row, unique on `(source, eventId)`. A duplicate delivery is a no-op.\n. Dispatch to the provider's registry, which instantiates the handler for that event type.\n. Return `200` with `{ received: true }` even if the handler failed — the raw event is stored\n  and can be replayed, and a `500` would only trigger provider retries of work that may have\n  partially succeeded.\n\nSee xref:events:index.adoc[] for the dispatchers and handlers.\n\n== Machine request\n\n`pg_cron`, Supabase Edge Functions and other internal services call broadcast and cron\nendpoints with `Authorization: Bearer <INTERNAL_API_SECRET>`, verified by\n`verifyInternalSecret()` in `lib/auth/internal-secret.ts`. It *fails closed*: if the secret is\nunset, every request is rejected.\n\nThe MCP endpoint uses its own shared secret (`MCP_API_SECRET`) plus an explicit\n`x-subscription-id` header instead of a cookie — see xref:api:mcp.adoc[].\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:multi-tenancy.adoc","title":"Multi-tenancy","description":"How workspace isolation is resolved and enforced.","url":"https://docx.payability.com/dialer-v2/architecture/multi-tenancy.html","navOrder":8,"source":"= Multi-tenancy\n:description: How workspace isolation is resolved and enforced.\n\nEvery tenant-scoped row carries a `subscriptionId`. Isolation is enforced in application code\nat the repository layer, not by PostgreSQL row-level security.\n\n== Resolving the active workspace\n\n`resolveSubscription()` in `lib/auth/subscription.ts` is the single entry point. It returns a\ndiscriminated union so callers cannot forget the failure case:\n\n[source,typescript]\n----\ntype SubscriptionResult =\n  | { success: true;  context: { userId: string; subscriptionId: string; email?: string } }\n  | { success: false; error: string; status: number }\n----\n\nResolution order:\n\n. Authenticate the Supabase user; no user → `401 Unauthorized`.\n. Read the requested workspace from the `x-subscription-id` header, falling back to the\n  `active_subscription_id` cookie.\n. Look up the `Profile` for `supabaseUserId`; none → `403 No profile found for user`.\n. If a workspace was requested, verify a `SubscriptionUser` row exists for\n  `(subscriptionId, profileId)`; otherwise → `403 Forbidden: not a member of this subscription`.\n. If none was requested, default to the workspace the user *owns*, else their oldest\n  membership; none at all → `403 No subscription found for user`.\n. Stamp Sentry with the user and a `subscriptionId` tag, then return the context.\n\n`requireSubscription()` is the throwing variant, raising `SubscriptionError(message, status)`.\n\n== Setting the active workspace\n\nThe cookie is only ever written by the server:\n\n* `POST /api/subscriptions/active` sets `active_subscription_id` on the response.\n* `POST /api/auth/signout` clears it.\n* Client code never writes the cookie directly — the sidebar workspace switcher posts to the\n  endpoint and then invalidates React Query caches.\n\n`GET /api/me` returns the current user, their profile within the active workspace, the list of\nworkspaces they belong to, and `active_subscription_id`. The dashboard will not render until\nthis call resolves.\n\n== Roles\n\n`SubscriptionUser.role` is `owner`, `admin` or `member`.\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| Action | owner | admin | member\n\n| Read workspace data | ✓ | ✓ | ✓\n| Add a member | ✓ | ✓ | —\n| Change a member's role | ✓ | ✓ (not to/from owner) | —\n| Remove a member | ✓ | ✓ (not an owner) | —\n| Assign the `owner` role | ✓ | — | —\n|===\n\nOnly owners may remove owners or grant ownership. Enforced in the\n`/api/subscriptions/members` routes — see xref:api:auth-workspace.adoc[].\n\n== Scoped models\n\nAlmost every domain model carries `subscriptionId`: `Campaign`, `CampaignTarget`, `Call`,\n`CallAiAgentAnalysisResult`, `PhoneNumbers`, `AiAgent`, `AiAgentVersion`, `Configs`,\n`AgentTestCase`, `Activity`, `Seller`, `Contact`, `EmailCampaignTarget`, `EmailVariation`,\n`Domain`, `EmailSendingDomain`, `TargetPhoneNumbers`, `TargetEmails`, `Emails`, `SalesRep`,\n`FollowUpTask`, `SequenceTemplate`, `ContactSequence`, `BlockedPhoneNumbers`.\n\nNotable exceptions and their reasons:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Model | Why it is not scoped\n\n| `Profile` | Identity is global; workspace membership is expressed by `SubscriptionUser`\n| `WebhookEvent` | Raw provider payload captured before a tenant is known\n| `SequenceTemplateStep`, `ContactSequenceStep` | Scoped transitively through their parent\n| `Call.subscriptionId` | Nullable, because Nooks calls may arrive before a workspace match\n| `AiAgent.subscriptionId` | Nullable, to allow shared/global agents\n|===\n\nWhere a model's `subscriptionId` is nullable, queries must still constrain it — use\n`lib/utils/call-subscription-scope.ts` for calls, which builds the correct `where` fragment.\n\n== Uniqueness is per workspace\n\nComposite unique constraints encode the tenancy boundary, so the same email or phone number can\nexist in two workspaces:\n\n* `Contact`: `(subscriptionId, email)`, `(subscriptionId, phone)`,\n  `(subscriptionId, hubspotContactId)`, `(subscriptionId, supplierKey)`\n* `TargetEmails`: `(subscriptionId, email)`\n* `SalesRep`: `(subscriptionId, email)`\n* `Activity`: `(subscriptionId, externalId)`\n* `Emails`: `(subscriptionId, externalId)`\n* `Domain`: `(subscriptionId, name)`\n\n`TargetPhoneNumbers.phoneNumber` and `PhoneNumbers.phoneNumber` are globally unique — a\ncarrier number can only belong to one workspace.\n\n== Review checklist\n\n* Did the route call `resolveSubscription()` and handle `success: false`?\n* Does every `where` in the repository include `subscriptionId`?\n* For nullable-scope models, is the null case handled deliberately rather than by accident?\n* Does a new unique constraint include `subscriptionId` unless the value is globally unique?\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:async-runtime.adoc","title":"Asynchronous runtime","description":"How work escapes the request/response cycle — Inngest, cron broadcasters, the Redis counter and SQS.","url":"https://docx.payability.com/dialer-v2/architecture/async-runtime.html","navOrder":9,"source":"= Asynchronous runtime\n:description: How work escapes the request/response cycle — Inngest, cron broadcasters, the Redis counter and SQS.\n\nAnything slow, retryable or scheduled leaves the HTTP request. Four mechanisms do this work.\n\n== 1. Inngest durable functions\n\nFunctions live in `lib/workflows/functions/` and are served together from\n`app/api/inngest/route.ts`:\n\n[source,typescript]\n----\nexport const { GET, POST, PUT } = serve({\n  client: inngest,\n  functions: [\n    testContextCollection,\n    processRequest,\n    purchasePhoneNumber,\n    callTarget,\n    callEndedAnalysis,\n    nooksCallAnalysis,\n    emailSend,\n    emailCampaignDuplicate,\n    emailCampaignImportSegment,\n    segmentRefresh,\n    sequenceExecute,\n  ],\n})\n----\n\nThe client (`lib/plugins/inngest-client.ts`) has id `dialer-v2-app` and installs\n`@inngest/middleware-sentry`, so every step failure is reported with workflow context.\n\nEach side effect is its own `step.run`, which means it retries independently and is skipped on\nreplay. `step.sleep` is used for real delays (the double-tap minute, sequence waits, gate\nretry windows) and does not consume concurrency while sleeping.\n\nEvent names in use:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Event | Function | Emitted by\n\n| `call-target/call.requested` | `callTarget` | Campaign broadcast; `callEndedAnalysis` double tap\n| `call-target/call.ended` | `callEndedAnalysis` | The `call-ended` webhook handler\n| `nooks/call.logged` | `nooksCallAnalysis` | The Nooks `call.logged` handler\n| `email-campaign/send.requested` | `emailSend` | Email campaign broadcast\n| `email-campaign/duplicate.requested` | `emailCampaignDuplicate` | `+POST /api/email-campaigns/{id}/duplicate+`\n| `email-campaign/import-segment.requested` | `emailCampaignImportSegment` | `+POST /api/email-campaigns/{id}/targets/from-segment+`; optional `segment_id` on create\n| `segment/refresh.requested` | `segmentRefresh` | `+POST /api/segments/{id}/refresh+` or `segmentRefreshCron` (06:00 and 18:00 ET) — one event per active Outreach or BigQuery strategy. Steps: verify, create run, fill staging, insert (cap), reconcile removals when not truncated, finalize.\n| `sequence/execute` | `sequenceExecute` | Sequence enrollment / start\n| `sequence/cancel` | — (cancels `sequenceExecute`) | Sequence stop\n| `phone-number/purchase.requested` | `purchasePhoneNumber` | Phone-number selector, when no number is eligible\n|===\n\nSee xref:workflows:index.adoc[] for each function's steps and guards.\n\n== 2. Cron broadcasters\n\nCampaign loops are pull-based. A scheduler calls a broadcast endpoint, which leases a batch of\ndue work and enqueues one Inngest event per item.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Scheduler | Target | Cadence\n\n| `pg_cron` (Supabase) | `+POST /api/campaigns/{id}/broadcast+` | Per campaign\n| `pg_cron` (Supabase) | `+POST /api/email-campaigns/{id}/broadcast+` | `Campaign.schedulerIntervalSeconds` (default 30 s)\n| Inngest cron | `phoneNumberDailyResetCron` | Daily at 00:00 UTC; zeroes `PhoneNumbers.dailyCallCount`\n| Inngest cron | `sendingDomainDailyResetCron` | Daily at 00:00 UTC; zeroes `EmailSendingDomain.dailySentCount`\n| Inngest cron | `phoneNumberOptimizerCron` | Weekly, Monday 11:15 UTC; releases unused phone numbers\n| Inngest cron | `segmentRefreshCron` → `segment/refresh.requested` | 06:00 and 18:00 America/New_York\n| Inngest cron | `webhookEventPurgeCron` | Daily at 03:00 America/New_York; deletes `WebhookEvent` rows older than seven days\n|===\n\nBoth broadcast endpoints are gated by `verifyInternalSecret()`. Inngest cron schedules are declared\non each function in `lib/workflows/functions/`; the app has no Vercel crons. See\nxref:ops:deployment-and-jobs.adoc[].\n\n== 3. The Redis in-flight counter\n\nA campaign's concurrency cannot be derived from the database cheaply, because a call is\nin-flight from the moment an Inngest event is sent until the provider webhook lands. A Redis\ncounter bridges that window.\n\n`lib/redis/call-target-counter.ts` exposes `incrementCallTargetRuns(campaignId)` and\n`decrementCallTargetRuns(campaignId)`.\n\n* The broadcaster reads the counter first. The ceiling is `maxConcurrentCalls × 2`; if\n  `currentRuns` is already at or above it, no events are sent for that campaign this tick and the\n  result reports `throttled`. Otherwise only `maxInflight - currentRuns` events are enqueued,\n  even if more targets were leased.\n* Every successfully enqueued `call-target/call.requested` increments the counter.\n* `callTarget` decrements it on *every* exit path through a `finish()` helper, so a skipped or\n  failed dial does not leak capacity.\n* `callEndedAnalysis` increments it again before re-firing a double tap, because that re-fire\n  creates a new in-flight call.\n\nThe `× 2` headroom exists because one target can legitimately have more than one event in\nflight around a double tap, and because a leased target may still be waiting for its first\nInngest step to start.\n\n== 4. AWS SQS\n\nFire-and-forget messages to downstream Payability services. Publishers live in\n`lib/plugins/sqs-client.ts`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Queue | `event_type` values | Producer\n\n| `CRM_QUEUE_URL`\n| `Crm.DialerCallEnded`, `Crm.DealUpdate`, `Crm.CustomEvent`, `Crm.NewActivity`,\n`Crm.HubSpotWebhook`, `Crm.TaskCreate`, `Contact.Update`, `Contact.Unsubscribe`\n| Call-ended handler, Nooks handler, Resend handlers, HubSpot webhook, sequence `crm_task` and\n`crm_event` steps, unsubscribe endpoint\n\n| `INCOMING_EMAIL_SQS_QUEUE_URL`\n| `Email.IncomingCategorise`\n| The Resend `email.received` handler\n\n| `EMAIL_VARIATION_QUEUE_URL`\n| Email variation generation requests\n| `lib/services/email-variation-sqs.ts`\n|===\n\nCRM publishes are deliberately conditional: handlers only enqueue when a HubSpot contact id is\navailable, so downstream consumers never receive an unresolvable payload. See\nxref:events:crm-sqs.adoc[].\n\n== Choosing a mechanism\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Need | Use\n\n| Must survive a crash and retry a specific step | Inngest function\n| Must run on a wall-clock schedule | Cron endpoint that enqueues Inngest events\n| Must respect a real-time concurrency ceiling | Redis counter checked by the broadcaster\n| Hand off to another service, no result needed | SQS publish\n| Cheap, best-effort side effect inside a request | `await` it in a `try/catch` that logs and continues\n|===\n"},{"component":"dialer-v2","module":"architecture","path":"architecture:cross-cutting.adoc","title":"Cross-cutting concerns","description":"Observability, time handling, caching, performance and scalability.","url":"https://docx.payability.com/dialer-v2/architecture/cross-cutting.html","navOrder":10,"source":"= Cross-cutting concerns\n:description: Observability, time handling, caching, performance and scalability.\n\n== Observability\n\nSentry::\nInitialised in three places — `sentry.client.config.ts`, `sentry.server.config.ts`,\n`sentry.edge.config.ts` — plus `instrumentation.ts` and `instrumentation-client.ts`.\n`resolveSubscription()` sets the user and a `subscriptionId` tag on every authenticated\nrequest, and `@inngest/middleware-sentry` attaches workflow context to step failures.\n\nStructured console logging::\nHandlers and workflows log with a bracketed source prefix, for example\n`📡 [Nooks] call.logged — eventId=…` or `[Phone Number Optimizer] Released 3 numbers`. Vercel\nlog search relies on these prefixes.\n\nWebhook audit trail::\nEvery inbound webhook is persisted to `WebhookEvent` with its raw payload before dispatch, so\nany event can be inspected or replayed after the fact.\n\nAnalysis audit trail::\n`CallAiAgentAnalysisResult` is unique on `(callId, aiAgentId, aiAgentVersionId)`, which makes\nit a record of *which agent version* produced which verdict — the basis of the per-agent\nperformance report.\n\n== Time handling\n\nTime is the most common source of subtle bugs in this system. Three rules:\n\n. *Timestamps are stored in UTC.* Scheduling columns use `@db.Timestamptz(6)`.\n. *Call windows are evaluated in the target's timezone.* `CampaignTarget.tz` is derived from\n  the number's area code (`lib/utils/area-code-timezones.ts`, `lib/utils/state-area-codes.ts`)\n  and defaults to `America/New_York`. `PhoneNumberCallingService.checkCallWindow` converts the\n  current UTC instant into that zone using `Intl` before comparing against\n  `Campaign.windowStart`/`windowEnd`.\n. *Report and filter date ranges are Eastern Time.* `parseDateInNY(dateStr, endOfDay?)` in\n  `lib/utils.ts` turns a `YYYY-MM-DD` string into the UTC instant of midnight or 23:59:59 in\n  `America/New_York`, DST-aware via `Intl.DateTimeFormat` with no external library. Call list\n  timestamps are rendered in Eastern Time with the zone abbreviation so the display matches the\n  filter semantics.\n\n`TIMEZONES` in `lib/constants.ts` is the allowlist offered in the UI.\n\n== Caching\n\n* React Query is the client cache. Query keys follow a stable convention\n  (`[\"campaigns\"]`, `[\"campaign\", id]`, `[\"campaignTargets\", campaignId, params]`) and every\n  mutation invalidates the related keys. See xref:frontend:data-layer.adoc[].\n* Fetches use `cache: \"no-store\"` so Next.js does not add a second, invisible cache layer in\n  front of React Query.\n* Prisma connects through Supabase's pooler; `DIRECT_URL` is reserved for migrations.\n  `@prisma/extension-accelerate` is available for query caching where a workload justifies it.\n\n== Performance\n\nQuery shape::\nList endpoints share a `where` builder between the page query and its count query, so filters\ncan never diverge between the two. Stats come from `groupBy` aggregations rather than loading\nrows into memory.\n\nIndexes::\nComposite indexes exist for the hot paths: `CampaignTarget(campaignId, state)`,\n`CampaignTarget(nextEarliestAttemptAt)`, `CampaignTarget(leaseExpiresAt)`,\n`Call(campaignId, status)`, `Call(targetPhoneNumber, campaignId, createdAt)`,\n`Call(targetPhoneNumber, disposition)`, `Emails(subscriptionId, createdAt DESC)`,\n`ContactSequence(status, nextStepAt)`.\n\nLeasing instead of locking::\nTarget selection uses a lease timestamp, not a database lock, so a crashed worker releases its\nclaim by expiry rather than requiring cleanup.\n\nPagination everywhere::\nEvery list endpoint returns `{ data, total, page, pageSize }` and enforces a maximum page size\n(commonly 100 or 500).\n\n== Scalability\n\n* Route handlers are stateless and horizontally scalable; the only shared mutable state outside\n  PostgreSQL is the Redis counter.\n* Broadcast loops are pull-based, so adding campaigns adds ticks rather than long-running\n  processes.\n* Inngest applies its own concurrency limits per function (`callTarget` limit 5,\n  `sequenceExecute` limit 50), which caps downstream provider load independently of how many\n  events are enqueued.\n* Sleeping Inngest runs do not hold concurrency slots, so a sequence with a 7-day wait costs\n  nothing while waiting.\n\n== Known constraints\n\n* No automated test suite exists. Write pure functions and inject dependencies so that logic\n  *can* be tested, but do not add test infrastructure unless it is explicitly requested.\n* `lib/mock-data.ts` still backs a few placeholder screens.\n"},{"component":"dialer-v2","module":"domain","path":"domain:index.adoc","title":"Business logic","description":"The rules Dialer V2 enforces, organised by capability.","url":"https://docx.payability.com/dialer-v2/domain/index.html","navOrder":11,"source":"= Business logic\n:description: The rules Dialer V2 enforces, organised by capability.\n\nThis module documents *what the product does and why*. Where a rule has a non-obvious reason —\na guard that exists because of a race condition, a cap that exists because of a provider limit —\nthat reason is recorded alongside the rule.\n\n== Capability map\n\n[mermaid]\n....\nflowchart TB\n  subgraph Acquire[\"Acquire\"]\n    Sellers[\"Sellers\\n(SmartScout / manual)\"]\n    Contacts[\"Contacts\"]\n    Registries[\"Phone & email registries\"]\n  end\n\n  subgraph Engage[\"Engage\"]\n    CallCamp[\"Call campaigns\"]\n    EmailCamp[\"Email campaigns\"]\n    Sequences[\"Nurture sequences\"]\n  end\n\n  subgraph Understand[\"Understand\"]\n    Analysis[\"LLM call analysis\"]\n    Inbox[\"Email inbox & reply categorisation\"]\n    Reports[\"Reports\"]\n  end\n\n  subgraph Handoff[\"Hand off\"]\n    Tasks[\"Follow-up tasks\"]\n    CRM[\"HubSpot / CRM queue\"]\n  end\n\n  Sellers --> Contacts --> Registries\n  Registries --> CallCamp\n  Registries --> EmailCamp\n  Contacts --> Sequences\n  CallCamp --> Analysis\n  EmailCamp --> Inbox\n  Analysis --> Reports\n  Inbox --> Reports\n  Analysis --> CRM\n  Sequences --> Tasks --> CRM\n....\n\n== The invariants that matter most\n\nNever contact a suppressed endpoint::\nA phone number is undialable if its `TargetPhoneNumbers` row is blocked or deleted, or its\n`riskLevel` is `medium` or worse. An email address is unsendable if its `TargetEmails` row has\n`newsletterOptOut`, `marketingOptOut` or `isBlocked`. Both checks happen twice — once when the\nbroadcaster leases work, once inside the workflow — because the lease and the send are minutes\napart. See xref:contacts.adoc#registries[Registries].\n\nNever dial the same number twice at once::\nEnforced in four independent places: the `pg_cron` broadcaster's Redis in-flight ceiling, the\ndatabase lease on `CampaignTarget.leaseExpiresAt`, an Inngest `rateLimit` of 2 runs per\n`targetPhoneNumber` per 24 h on `callTarget`, and a \"first call today\" guard on the double tap.\nSee xref:target-lifecycle.adoc[].\n\nNever send the same email twice::\nEnforced by `queuedAt` locking in the broadcaster and by the `emailSend` workflow's first step,\nwhich refuses any target that has already left `pending`. See xref:email-campaigns.adoc[].\n\nRespect the recipient's clock::\nCall windows are evaluated in the *target's* timezone, derived from the number's area code.\nSee xref:target-lifecycle.adoc#call-windows[Call windows].\n\nEvery LLM verdict is attributable::\n`CallAiAgentAnalysisResult` is unique on `(callId, aiAgentId, aiAgentVersionId)`, so a score can\nalways be traced to the exact prompt version that produced it. See xref:call-analysis.adoc[].\n\nCaps are enforced at every level that has one::\nPer variation per hour, per email account per day, per parent domain per day, per campaign per\nday. All four are checked before a send. See xref:email-deliverability.adoc[].\n\n== Reading order\n\nFor the dialer: xref:call-campaigns.adoc[] → xref:target-lifecycle.adoc[] →\nxref:call-analysis.adoc[] → xref:phone-numbers.adoc[].\n\nFor email: xref:email-campaigns.adoc[] → xref:email-deliverability.adoc[] →\nxref:email-inbox.adoc[].\n\nFor nurture: xref:contacts.adoc[] → xref:sequences.adoc[] → xref:sales-reps.adoc[].\n"},{"component":"dialer-v2","module":"domain","path":"domain:call-campaigns.adoc","title":"Call campaigns","description":"Campaign configuration, the broadcast loop and the dial pipeline.","url":"https://docx.payability.com/dialer-v2/domain/call-campaigns.html","navOrder":12,"source":"= Call campaigns\n:description: Campaign configuration, the broadcast loop and the dial pipeline.\n\nA call campaign is a `Campaign` row with `type = call`. It owns a set of `CampaignTarget` rows,\neach one phone number with its own state machine.\n\nThe `/campaigns` list shows *call campaigns only*. Opening a voice campaign URL for a campaign\nwhose `type` is `email` redirects to the email campaign detail page.\n\n== Configuration\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Field | Default | Meaning\n\n| `status` | `draft` | `draft`, `running`, `paused`, `stopped`, `completed`\n| `maxConcurrentCalls` | `10` | Targets leased per broadcast tick, and the basis of the in-flight ceiling\n| `maxAttempts` | `null` | Retry ceiling per target. Fallbacks differ by code path — see the note below\n| `waitHoursToRecall` | `24` | Cooling-off hours before a recoverable target is dialable again\n| `windowStart` / `windowEnd` | `null` | Time-of-day window, evaluated in the *target's* timezone\n| `allowWeekends` | `false` | When false, Saturday and Sunday are skipped in the target's timezone\n| `preferedPhone` | `null` | Preferred outbound number\n| `preferredAiAgentId` | `null` | Preferred *analysis* agent for this campaign's calls\n| `schedulerIntervalSeconds` | `30` | How often `pg_cron` calls the broadcast endpoint\n| `subscriptionId` | — | Owning workspace\n|===\n\n[NOTE]\n====\n`maxAttempts` has three different fallbacks depending on which code reads it:\n\n* the `call-ended` webhook handler uses `campaign.maxAttempts ?? 10`\n* `RETRY_CONSTANTS.DEFAULT_MAX_ATTEMPTS` is `4`\n* the double-tap guard in `callEndedAnalysis` applies *no* ceiling when `maxAttempts` is null\n\nSet `maxAttempts` explicitly on every campaign rather than relying on a default.\n====\n\n== Campaign lifecycle\n\n[mermaid]\n....\nstateDiagram-v2\n  [*] --> draft\n  draft --> running\n  running --> paused\n  paused --> running\n  running --> stopped\n  running --> completed\n  stopped --> [*]\n  completed --> [*]\n....\n\nOnly a `running` campaign broadcasts. `processBroadcast` throws\n`CallInitiationError('CAMPAIGN_NOT_RUNNING')` for anything else, so pausing a campaign stops\nnew dials on the next tick without touching leased targets.\n\n== The broadcast loop\n\n`+POST /api/campaigns/{id}/broadcast+` is an *internal-only* endpoint gated by\n`verifyInternalSecret()`. `pg_cron` calls it on the campaign's interval. The route passes a\nlease of one day (`60 × 60 × 24` seconds) because a queued call can wait hours before Retell\ndials it; the lease is cleared when the call ends.\n\n[mermaid]\n....\nflowchart TD\n  Cron[\"pg_cron tick\"] --> Route[\"POST /api/campaigns/{id}/broadcast\\n(internal secret)\"]\n  Route --> Svc[\"PhoneNumberCallingService.processBroadcast(campaignId, 86400)\"]\n  Svc --> S1{\"Campaign exists\\nand status = running?\"}\n  S1 -->|No| Err[\"Throw CallInitiationError\"]\n  S1 -->|Yes| S2[\"Supabase RPC get_and_lease_due_targets\\n(batch = maxConcurrentCalls, lease = 86400s)\"]\n  S2 --> S3{\"Any targets?\"}\n  S3 -->|No| Idle[\"Return hasAvailableTargets: false\"]\n  S3 -->|Yes| S4{\"Redis in-flight >= maxConcurrentCalls x 2?\"}\n  S4 -->|Yes| Throttle[\"Return throttled — retry next tick\"]\n  S4 -->|No| S5[\"toEnqueue = min(targets, maxInflight - currentRuns)\"]\n  S5 --> S6[\"Per target: check TargetPhoneNumbers registry\"]\n  S6 --> S7{\"Eligible?\"}\n  S7 -->|No| Skip[\"state = skipped, clear lease, record lastError\"]\n  S7 -->|Yes| S8[\"inngest.send call-target/call.requested\\nthen incrementCallTargetRuns\"]\n....\n\n=== Target leasing\n\n`get_and_lease_due_targets` is a Supabase RPC (a PostgreSQL function). It atomically selects up\nto `p_batch_size` due targets and stamps `leaseExpiresAt = now() + p_lease_seconds`, so two\nconcurrent ticks cannot lease the same row. A crashed worker releases its claim by expiry\nrather than needing cleanup.\n\nThe lease skips a target whose `TargetPhoneNumbers` row has `isBlocked = true`. Resolution matches\nthe broadcaster: the `targetPhoneNumbersId` foreign key when it is set, otherwise the phone number\nplus the campaign workspace, ignoring deleted registry rows. A target with no registry row stays\neligible. Deleted and high-risk rows are still refused after the lease.\n\n=== In-flight throttling\n\nA call is in-flight from the moment the Inngest event is sent until the provider webhook lands,\nwhich the database cannot see cheaply. A Redis counter bridges that window:\n\n* Ceiling is `maxConcurrentCalls × 2`. The doubled headroom accommodates a target that has a\n  double tap queued alongside its original run.\n* At or above the ceiling the tick returns `throttled` and enqueues nothing.\n* Below it, only `maxInflight - currentRuns` events are enqueued even if more targets were\n  leased. Surplus leased targets simply wait for their lease to expire.\n* `incrementCallTargetRuns` runs after each successful `inngest.send`; `callTarget` decrements\n  on every exit path.\n\n=== Registry gate at lease time\n\nBefore enqueuing, the broadcaster resolves the target's `TargetPhoneNumbers` row (by\n`targetPhoneNumbersId`, else by number within the campaign's workspace) and refuses to dial when:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Condition | `lastError` recorded\n\n| Registry row belongs to another workspace | `Target phone registry subscription mismatch`\n| `isBlocked = true` | `Target phone number is blocked`\n| `isDeleted = true` | `Target phone number registry row is deleted`\n| `riskLevel` is not `low` or null | `Target phone risk level (<level>) must be below medium`\n|===\n\nIn all four cases the target moves to `skipped` and its lease is cleared. A target with no\nregistry row at all is allowed through — the registry is an enrichment layer, not a\nprerequisite.\n\n== The dial pipeline\n\nThe `callTarget` Inngest function (`retries: 0`, `concurrency: { limit: 5 }`,\n`rateLimit: 2 per targetPhoneNumber per 24h`) performs three steps. `retries: 0` is deliberate:\na dial attempt is a discrete business event, and retrying it automatically would double-dial a\nprospect.\n\n. *`check-call-window`* — `checkCallWindow(campaignId, targetId)`. Outside the window or on a\n  disallowed weekend, exit with `skipReason: outside_call_window`. The lease is *not* cleared,\n  so the target is simply re-picked after expiry.\n. *`pre-validate`* — `preValidateTargetOnly(...)`, see below.\n. *`initiate-call`* — `initiateCall(...)`: select a source number, pick a caller agent, create\n  the Retell call, then upsert the `Call` row and set the target to `dialing` in one\n  transaction.\n\nEvery exit path calls a `finish()` helper that fire-and-forgets\n`decrementCallTargetRuns(campaignId)`.\n\n=== Pre-validation guards\n\nEvaluated in order; the first match wins.\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Guard | Check | Result\n\n| Target exists | `findCampaignTargetById` | `target_not_found`\n| DNC | Any target with this phone number is on the DNC list | `state = skipped`, reason `dnc`\n| Prior terminal disposition | A previous call to this number had a disposition in `SKIP_CALL_DISPOSITIONS` (`Do_Not_Call`, `Not_a_Seller`, `Amazon_Seller`) | `state = skipped`, `attempts = 9999`, reason `prior_disposition`\n| Contact lifecycle | The linked contact's stage is in `SKIP_CALL_LIFECYCLE_STAGES` (`customer`, `marketingqualifiedlead`) | `state = skipped`, `attempts = 9999`, reason `lifecycle_stage`\n| E.164 format | `/^\\+[1-9]\\d{1,14}$/` | reason `invalid_e164`\n|===\n\nSetting `attempts = 9999` is a belt-and-braces measure: even if the state were later reset, the\nattempt ceiling would keep the target out of the dial pool.\n\n`Amazon_Seller` appears in `SKIP_CALL_DISPOSITIONS` because a confirmed Amazon seller has already\nbeen qualified and is handed to sales — re-dialing them is a negative experience, not an\nopportunity.\n\n=== Call initiation\n\n. Select a source number via `PhoneNumberSelector` (see xref:phone-numbers.adoc[]). If none is\n  eligible, a purchase is requested asynchronously and the attempt exits with\n  `no_phone_number_available` — the next broadcast retries.\n. Pick a caller agent: a random active `caller`-type `AiAgent` for the workspace, falling back\n  to the static `RETELL_AGENT_LIST` in `scripts/retelai/agents/registry.ts`.\n. Build metadata. `targetPayload` values are stringified and null-coalesced to `\"\"`, because a\n  Retell agent will read a missing variable's *name* aloud.\n. Create the Retell call with `campaignId`, `campaignName`, `campaignTargetId`, `agentId` and\n  `agentVersionId` in both `metadata` and `retell_llm_dynamic_variables`. The webhook handler\n  relies on `metadata.campaignId` and `metadata.campaignTargetId` to correlate the call.\n. On a Retell error: set the target to `failed`, increment `attempts`, record `lastError`, and\n  return `skipped` with `retell_error` rather than throwing — a provider outage should not fill\n  Sentry with unhandled exceptions.\n. On success: `upsertCallAndSetTargetDialing` writes the `Call` row (status `dialing`,\n  `providerCallId`, `aiAgentId`, `aiAgentName`, `agentVersionId`, `phoneNumberId`) and moves the\n  target to `dialing` in a single transaction.\n\n[IMPORTANT]\n====\nThe `call_started` webhook handler exists (`lib/events/handlers/call-started.ts`) but is *not\nregistered* — the registration line is commented out in `lib/events/event-handler.ts`. The\n`dialing` state and the `Call` row are therefore created by `initiateCall`, not by a webhook, and\nthe attempt counter is incremented by the `call_analyzed` handler when the call ends.\n====\n\n== Coverage\n\n`+GET /api/campaigns/{id}/coverage+` reports how well the workspace's outbound numbers cover the\ncampaign's target geography — which states have targets but no local source number.\n`+POST /api/campaigns/{id}/coverage/recommendations+` asks an LLM\n(`lib/ai/services/coverage-recommendation-service.ts`) which area codes to buy next. See\nxref:api:campaigns.adoc#coverage[Coverage endpoints].\n"},{"component":"dialer-v2","module":"domain","path":"domain:target-lifecycle.adoc","title":"Target lifecycle","description":"The CampaignTarget state machine, retry strategy, call windows and leasing.","url":"https://docx.payability.com/dialer-v2/domain/target-lifecycle.html","navOrder":13,"source":"= Target lifecycle\n:description: The CampaignTarget state machine, retry strategy, call windows and leasing.\n\nA `CampaignTarget` is one phone number in one call campaign. Its `state`, `attempts`,\n`nextEarliestAttemptAt` and `leaseExpiresAt` together decide whether it is dialable right now.\n\n== States\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| State | Meaning\n\n| `pending` | Dialable once `nextEarliestAttemptAt` has passed\n| `in_progress` | Set by the `call_analyzed` handler when a retry has been scheduled\n| `dialing` | A call has been created with the provider\n| `completed` | Terminal — the call achieved its purpose\n| `failed` | Terminal — invalid destination, negative disposition, or attempts exhausted\n| `canceled` | Terminal — manually cancelled\n| `skipped` | Terminal — pre-validation refused the target (DNC, prior disposition, lifecycle stage, registry block)\n| `DNC` | Terminal — Do Not Call or wrong number\n| `WN` | Wrong number (declared in the enum; the handler currently routes wrong numbers to `DNC`)\n| `MAX` | Max attempts reached (declared in the enum; the handler currently routes this to `failed`)\n| `DT_PENDING` | Double-tap pending (declared in the enum; the current double tap runs in Inngest and does not set this state)\n| `VOICEMAIL` | Voicemail detected (declared in the enum; the handler currently routes voicemail to a retry)\n|===\n\n[NOTE]\n====\n`TargetState` carries more values than the current handler produces. `WN`, `MAX`, `DT_PENDING`\nand `VOICEMAIL` are legacy values from an earlier state machine and still appear on historical\nrows. Read them, but do not write them from new code without also updating the handler.\n====\n\n== Transitions\n\n[mermaid]\n....\nstateDiagram-v2\n  [*] --> pending\n  pending --> dialing : initiateCall\n  pending --> skipped : pre-validation refused\n  dialing --> DNC : DNC or wrong number\n  dialing --> failed : invalid destination / negative disposition / attempts exhausted\n  dialing --> in_progress : recoverable outcome, attempts remain\n  in_progress --> dialing : cooling-off elapsed, re-leased\n  dialing --> completed : anything else\n  dialing --> failed : Retell rejected the call\n  completed --> [*]\n  failed --> [*]\n  DNC --> [*]\n  skipped --> [*]\n....\n\nThe decision is made in the `call_analyzed` handler\n(`lib/events/handlers/call-ended.ts`) in strict priority order:\n\n[cols=\"1,3a\",options=\"header\"]\n|===\n| Priority | Rule\n\n| 1 — stop calling\n| `stopCalling` is true (DNC via `nextAction = Do_Not_Call` / disposition `Do_Not_Call` /\n`Remove_From_List`, or wrong number via disposition `Wrong_Number` / `Not_a_Seller`).\n→ `state = DNC`, `attempts += 999999` so no path can revive it.\n\n| 2 — invalid destination\n| Disposition is `invalid_destination`. → `state = failed`, `attempts += 999999`,\n`lastError = \"Invalid destination\"`.\n\n| 3 — recoverable\n| Any of: `inVoicemail`, `callScreeningDetected`, technical failure\n(`dial_no_answer`, `dial_busy`, `dial_failed`, `inactivity`, `machine_detected`), busy without a\ncallback request, `Hangup_Early`, duration under 10 s, or a disposition in\n`NEGATIVE_DISPOSITIONS_TO_RECALL` (`Gatekeeper`, `IVR`, `dial_no_answer`, `dial_busy`,\n`AI_Screening`).\n\nIf `attempts < (maxAttempts ?? 10)`: `state = in_progress`,\n`nextEarliestAttemptAt = now + waitHoursToRecall`. Otherwise the state falls through to\n`completed`.\n\n| 4 — default\n| `state = completed`.\n|===\n\nEvery branch also increments `attempts`, stamps `lastAttemptAt`, clears `leaseExpiresAt`, and\ncaches the call's business flags onto the target (`disposition`, `isAmazonSeller`,\n`meetsMonthlyThreshold`, `stopCalling`, `nextAction`, `lastCallRecordingUrl`,\n`validConversation = durationSec >= 10`).\n\n== Retry strategy\n\nTwo mechanisms operate at different timescales.\n\n=== Cooling off (database-driven)\n\nThe webhook handler sets `nextEarliestAttemptAt = now + waitHoursToRecall` (24 h by default,\nstored in UTC) for *every* recoverable outcome. The broadcast loop re-leases the target once that\ntime has passed.\n\nThis deliberately uses the full cooling-off window rather than a short one, so the broadcast loop\ncannot race the Inngest double tap and dispatch a second parallel attempt. If Inngest fails\nentirely, the cooling-off window is the fallback that eventually retries the target.\n\n=== Double tap (Inngest-driven)\n\nThe `callEndedAnalysis` function reacts to `call-target/call.ended` and performs an immediate\nsame-day retry when the *technical* disconnection reason is in `DOUBLE_TAP_REASONS`:\n\n[source]\n----\nDOUBLE_TAP_REASONS = [\n  \"voicemail_reached\",\n  \"machine_detected\",\n  \"dial_no_answer\",\n  \"user_declined\",\n]\n----\n\nTwo guards must both pass:\n\n. *First call today.* `countCallsTodayForTargetPhone(targetPhone, campaignId)` must be `1`. More\n  than one call today to that number in that campaign cancels the double tap.\n. *Attempts remain.* `target.attempts < campaign.maxAttempts`. When `maxAttempts` is null this\n  guard does not apply.\n\nIf both pass: `step.sleep('double-tap-wait', '1 minute')` → `incrementCallTargetRuns(campaignId)`\n→ re-send `call-target/call.requested` → return `{ doubleTapScheduled: true }`. AI analysis does\n*not* run on this path — the outcome will be analysed after the retry.\n\nIf either guard fails, execution falls through to the analysis steps rather than returning early.\n\n=== Hard ceiling\n\n`callTarget` declares an Inngest `rateLimit` of `limit: 2` per `event.data.targetPhoneNumber` per\n`24h`. Regardless of campaign configuration, broadcaster behaviour or double taps, a single phone\nnumber cannot be dialled more than twice in any 24-hour window. Excess events are dropped, not\nqueued.\n\n=== Summary\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Mechanism | Delay | Bounded by\n\n| Double tap | 1 minute | first-call-today guard, `maxAttempts`, Inngest rate limit\n| Cooling off | `waitHoursToRecall` (24 h) | `maxAttempts`\n| Rate limit | — | 2 dials per number per 24 h, absolute\n|===\n\n[#call-windows]\n== Call windows\n\n`PhoneNumberCallingService.checkCallWindow(campaignId, targetId)` is the first step of every dial:\n\n. Read `campaign.windowStart`, `campaign.windowEnd`, `campaign.allowWeekends`.\n. Read `CampaignTarget.tz`, derived from the number's area code and defaulting to\n  `America/New_York`.\n. Convert the current UTC instant into that timezone using `Intl`.\n. Return \"not in window\" when the local time is outside `[windowStart, windowEnd)`, or it is a\n  weekend and `allowWeekends` is false.\n\nOn failure the function exits without dialing and without clearing the lease, so the target is\nre-picked naturally once the lease expires.\n\nArea-code to timezone and state mappings live in `lib/utils/area-code-timezones.ts` and\n`lib/utils/state-area-codes.ts`.\n\n== Leasing\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Role\n\n| `leaseExpiresAt` | Set by `get_and_lease_due_targets` to `now + leaseSeconds` (86 400 s from the broadcast route). Cleared when the call ends or the target is skipped.\n| `nextEarliestAttemptAt` | Earliest instant the target may be leased again. `null` means \"immediately eligible\" for a `pending` target and \"never\" for a terminal one.\n| `attempts` | Incremented on every completed call and on a Retell failure. Sentinel values `9999` and `+999999` are used to permanently disqualify a target.\n|===\n\n== Post-call contact updates\n\nStep 2 of `callEndedAnalysis` propagates the outcome onto the contact graph, resolving the\ncontact through the phone registry (`TargetPhoneNumbers.contactId`):\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Outcome | Contact changes\n\n| DNC (`newState = \"DNC\"`)\n| `leadStatus = connected`, `lifecycleStage = other`, `stopCalling = true`,\n`nextAction = \"Do_Not_Call\"`; the `TargetPhoneNumbers` row is set `isBlocked = true`\n\n| Disposition `Amazon_Seller`\n| `leadStatus = connected`, `heatStatus = hot`, `lifecycleStage = marketingqualifiedlead`\n\n| Any other disposition\n| `leadStatus = attempted_to_contact`, `heatStatus = idle`, `lifecycleStage = opportunity`\n|===\n\nBlocking the registry row on DNC is what makes a Do Not Call request stick across *all* future\ncampaigns, not just the current one.\n\n== HubSpot eligibility\n\nA call is eligible for CRM hand-off when *all* of the following hold:\n\n* `durationSec` is set and `>= 10`\n* `inVoicemail` is false\n* `callScreeningDetected` is false\n* `disposition` is in `HUBSPOT_WHITE_LISTED_DISPOSITIONS`: `Qualified_Lead`, `Amazon_Seller`,\n  `Callback_Requested`, `Wrong_Number`, `Do_Not_Call`, `Not_a_Seller`, `AI_Issue`\n* the target is not a test target (`CampaignTarget.isTestTarget`)\n\n`HUBSPOT_BLOCKED_DISPOSITIONS` records the inverse set for reference: `AI_Screening`,\n`Voicemail_Detected`, `dial_busy`, `dial_no_answer`, `invalid_destination`, `Gatekeeper`, `IVR`.\n\nNote that negative outcomes such as `Do_Not_Call` and `Wrong_Number` *are* whitelisted — the CRM\nneeds to know about them in order to suppress future outreach.\n\nWhen eligible, the handler publishes `Crm.DialerCallEnded` to the CRM SQS queue with the\nworkspace id as the message group, rather than calling the HubSpot API inline. The direct\nHubSpot sync call remains in the source, commented out. See xref:events:crm-sqs.adoc[].\n"},{"component":"dialer-v2","module":"domain","path":"domain:call-analysis.adoc","title":"Call analysis","description":"How LLM analysis grades a call, where results are stored, and how to re-run them in bulk.","url":"https://docx.payability.com/dialer-v2/domain/call-analysis.html","navOrder":14,"source":"= Call analysis\n:description: How LLM analysis grades a call, where results are stored, and how to re-run them in bulk.\n\nEvery connected call is graded by an LLM against the caller agent's own script. The output is a\ndisposition verdict, a 1–10 agent score, a written summary and a list of issues.\n\n== When analysis runs\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Trigger | Path\n\n| A campaign call ends | `call-target/call.ended` → `callEndedAnalysis` step `run-ai-analysis`\n| A Nooks call is logged | `nooks/call.logged` → `nooksCallAnalysis`\n| An operator re-runs one call | `+POST /api/calls/{id}/analyze+`\n| An operator re-runs many calls | `POST /api/calls/batch-analyze`\n|===\n\n=== When it is skipped\n\nAnalysis is skipped when the technical disconnection reason is in\n`BLACKLISTED_AI_ANALYSIS_REASONS` — the call never reached a human, so there is nothing to grade:\n\n[source]\n----\nvoicemail_reached, dial_busy, dial_no_answer, dial_failed,\ntelephony_provider_unavailable, user_declined, invalid_destination,\ntelephony_provider_permission_denied\n----\n\nFor Nooks calls the equivalent set is `NOOKS_ANALYSIS_SKIP_DISPOSITIONS`:\n`\"No Answer\"`, `\"Voicemail\"`, `\"Busy\"`.\n\nBecause `voicemail_reached`, `machine_detected`, `dial_no_answer` and `user_declined` also appear\nin `DOUBLE_TAP_REASONS`, an eligible double tap returns before reaching the analysis step at all.\n\nTransient infrastructure failures — messages containing `EBUSY`, `ENOTFOUND`,\n`Cannot connect to API` or `Insufficient funds` — are swallowed and returned as\n`{ saved: false, error }`. Anything else re-throws, so a genuine bug surfaces in Sentry rather\nthan silently dropping analyses.\n\n== Agent selection\n\n`CallAnalysisService.runAndSave(callId, campaignId)`:\n\n. Load the call with its campaign. When `campaignId` is provided it must match the call's own\n  campaign, otherwise the run is rejected.\n. Choose the analysis agent:\n.. the campaign's `preferredAiAgentId`, if set (`getPreferredAgentIdForCampaign`);\n.. otherwise the workspace's default analysis agent (`getDefaultAnalysisAgentWithVersion`);\n.. no agent → `{ saved: false, error: \"No analysis AI agent configured\" }`.\n. Reject the run if the resolved agent's `type` is not `analysis`.\n. Resolve the *caller* agent's prompt for context: prefer `Call.agentVersionId` (the exact\n  version live during the call), else the latest version of `Call.agentId`.\n\nUsing the exact caller version matters: an analysis run months later must grade the call against\nthe script that was actually spoken, not the current one.\n\nFor human (Nooks) calls there is no caller agent, so\n`DEFAULT_HUMAN_CALL_ANALYSIS_SYSTEM_PROMPT` from `lib/constants.ts` is used instead. It reframes\nthe task as sales-coaching: verify the logged disposition, assess objection handling and rapport,\nand flag compliance or tone issues.\n\n== Prompt composition\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Part | Source\n\n| System prompt | The caller agent's `mainPrompt` (the script and persona) plus the `DEFAULT_POST_CALL_ANALYSIS` schema from `lib/retellai/post-call-analysis.ts`, so the model knows the field vocabulary\n| Criteria | The analysis agent's `mainPrompt`\n| Supplementary config | The analysis agent version's `configJson`\n| Model | `AiAgent.modelId` (a Vercel AI Gateway slug), falling back to the `llmModelType` default (`gpt` → `openai/gpt-4o`, `claude` → `anthropic/claude-sonnet-4`)\n|===\n\n=== Call facts supplied to the model\n\n`transcript`, `summary`, `disposition`, `retell_disposition`, `duration_seconds`, `duration_ms`,\n`user_sentiment`, `in_voicemail`, `call_screening_detected`, `ai_suspicion_detected`,\n`detected_language`, `disconnection_reason`, `target_phone_number`, `campaign_name`.\n\n== Output\n\n[source,typescript]\n----\ninterface CallAnalysisOutput {\n  correct_disposition: boolean\n  suggested_disposition?: string        // present when correct_disposition is false\n  agent_score: number                   // 1–10, how well the agent handled the conversation\n  summary: string\n  issues: Array<{\n    name: string\n    priority: \"critical\" | \"high\" | \"low\"\n    description: string\n  }>\n}\n----\n\nPersisted to `CallAiAgentAnalysisResult`, unique on `(callId, aiAgentId, aiAgentVersionId)`. The\nsame call can therefore hold one result per agent version — re-running with a *new* prompt version\nadds a row, while re-running the *same* version overwrites it.\n\n== Batch re-analysis\n\nUsed after changing a prompt, adding an analysis agent, or backfilling a date range.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Filter | Query / body key | Applied to\n\n| Campaign | `campaign_id` | `Call.campaignId`\n| Status | `status` | `Call.status`\n| Date range | `date_from`, `date_to` | `Call.createdAt` (inclusive; `date_to` is end of day)\n| HubSpot sync | `hubspot_sync_status` | `Call.hubspotSyncStatus` (`synced`, `failed`, `skipped`); omit or `all` for no filter\n|===\n\nAll filters are optional; empty or `\"all\"` values are not applied.\n\nFlow:\n\n. `GET /api/calls/batch-analyze` returns a preview count, so the operator sees the blast radius\n  before committing.\n. `POST /api/calls/batch-analyze` starts `batchCallAnalysisWorkflow(filters)` fire-and-forget and\n  returns immediately.\n. Step `getCallsToAnalyzeStep` calls `CallRepository.findCallsForBatchAnalysis(filters)`, which\n  returns `{ id, campaignId }[]` ordered by `createdAt` descending. The workflow never touches\n  Prisma directly.\n. Step `runCallAnalysisAndSaveStep` runs `CallAnalysisService.runAndSave(callId, campaignId)` per\n  call — the same code path as per-call analysis, so there is no second implementation to drift.\n\n`findCallsForBatchAnalysis` and `countCallsForBatchAnalysis` share the private\n`buildBatchAnalysisWhere(filters)` helper, so the preview count and the executed set can never\ndisagree.\n\nThe dialog (`components/campaign/calls/batch-re-analyze-calls-dialog.tsx`) appears on the Calls\npage and on the campaign Calls tab; on the campaign tab the campaign filter is fixed and hidden.\nHooks are `useBatchAnalyzePreview(filters, enabled)` and `useStartBatchAnalyze()` in\n`lib/hooks/use-batch-call-analyze.ts`.\n\n== Reviewing results\n\n`/agents/analysis` lists every `CallAiAgentAnalysisResult` for the workspace, backed by\n`GET /api/ai-agents/analysis`.\n\n* *Columns* — call phone, campaign, AI agent, analysis created, call created, score, disposition,\n  issue count, summary, and a link to the call.\n* *Filters* — `dateFrom` / `dateTo` (on the *analysis* `createdAt`, not the call's), `campaignId`,\n  `aiAgentId`, `hasIssues`.\n* *Export* — CSV of the current page.\n\nTwo distinct timestamps are surfaced because they answer different questions: \"when did this\nconversation happen\" versus \"when did we last grade it\".\n\n`/agents/issues` (`GET /api/ai-agents/issues`) flattens the `issues` arrays into a searchable\nlist with `name`, `priority`, `agentName`, `agentVersion` and date filters — the entry point for\nfinding systemic prompt problems. `POST /api/ai-agents/issues/suggest-prompt` asks an LLM\n(`lib/ai/services/prompt-suggestion-service.ts`) for a prompt revision that would address a\nselected issue.\n"},{"component":"dialer-v2","module":"domain","path":"domain:ai-agents.adoc","title":"AI agents and test cases","description":"Versioned caller and analysis agents, Retell synchronisation, regression test cases and prompt suggestions.","url":"https://docx.payability.com/dialer-v2/domain/ai-agents.html","navOrder":15,"source":"= AI agents and test cases\n:description: Versioned caller and analysis agents, Retell synchronisation, regression test cases and prompt suggestions.\n\nAn `AiAgent` is a named LLM configuration owned by a workspace. `AiAgentVersion` rows make every\nprompt change auditable, and `AiAgent.latestVersionId` points at the published one.\n\n== Agent types\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `type` | Role\n\n| `caller` | Speaks on outbound calls. Mirrored to Retell AI via `retellAgentId`; `AiAgentVersion.configJson` holds the full Retell agent config\n| `analysis` | Grades completed calls. Its `mainPrompt` supplies the grading criteria\n| `sms` | SMS conversations\n| `other` | Anything else\n|===\n\n`AiAgent.subscriptionId` is nullable, allowing a shared agent across workspaces.\n`AiAgent.retellAgentId` is globally unique, so no two records can claim the same Retell agent.\n\n== Versioning\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `version` | 1-based, unique per `(aiAgentId, version)`\n| `mainPrompt` | The script (caller) or the grading criteria (analysis)\n| `configJson` | Full Retell `IAgentData` for callers; model overrides and supplementary config for analysis agents\n| `modelId` | Vercel AI Gateway slug on the parent `AiAgent`, e.g. `openai/gpt-5`\n| `llmModelType` | Legacy `gpt` \\| `claude` fallback when `modelId` is unset\n|===\n\nModel resolution is `getModelSlug(modelId, llmModelType)` in `lib/ai/config.ts`: an explicit\n`modelId` wins, otherwise `gpt` → `openai/gpt-4o` and `claude` → `anthropic/claude-sonnet-4`.\nSelectable models are enumerated in `AI_GATEWAY_MODELS` across OpenAI, Anthropic, xAI and Google.\n\nVersions are never mutated in place. `Call.agentVersionId` records which version spoke, and\n`CallAiAgentAnalysisResult.aiAgentVersionId` records which version graded — this is what makes the\nper-agent performance report meaningful.\n\n== Caller agent selection at dial time\n\n`PhoneNumberCallingService.pickOverrideAgent(subscriptionId)`:\n\n. Query active `caller`-type agents for the workspace\n  (`AiAgentRepository.findCallerAgentsBySubscription`) and pick one at random. Random selection\n  spreads load and voice variety across the pool rather than favouring one agent.\n. If the workspace has none, fall back to the static `RETELL_AGENT_LIST` in\n  `scripts/retelai/agents/registry.ts` — the single source of the fallback id/name list.\n\nThe chosen Retell agent id is sent as `override_agent_id` on the call. `Call.aiAgentId` and\n`Call.aiAgentName` are denormalised onto the call row so the Calls table can render\n\"Brian v3\" without a join. `Call.agentId` is the `AiAgent` UUID; do not confuse it with\n`aiAgentId`, which is the Retell identifier.\n\n== Retell synchronisation\n\n`RetellAgentService` (`lib/services/retell-agent-service.ts`) wraps the Retell platform:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Method | Purpose\n\n| `createAgent`, `getAgent`, `updateAgent`, `deleteAgent` | Agent CRUD\n| `createAgentVersion(retellAgentId, baseVersion)` | Branch a new Retell version from an existing one\n| `publishAgent(retellAgentId, version)` | Make a version live\n| `createLLM`, `getLLM`, `updateLLM` | The response engine behind an agent\n| `syncAgent(...)` | Reconcile a local `AiAgentVersion` with Retell\n|===\n\nEvery create, update and delete through `/api/ai-agents` is mirrored to Retell, so the database\nand the platform do not drift.\n\n=== Voice and behaviour configuration\n\n`lib/retellai/agent-config.ts` holds the UI's option lists and the non-negotiable defaults:\n\n* `RETELL_VOICE_MODELS` — the available voice models.\n* `RETELL_LLM_MODELS` — response-engine models offered by Retell.\n* `RETELL_EXPRESSIVE_EMOTION_TAGS` — emotion tags for platform voices only, offered as a\n  multi-select.\n* `HARDCODED_AGENT_CONFIG` — applied to every caller agent:\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Setting | Value | Why\n\n| `stt_mode` | `fast` | Latency matters more than transcript perfection on a cold call\n| `end_call_after_silence_ms` | `25000` | Hang up rather than burning minutes on dead air\n| `max_call_duration_ms` | `80000` | Hard ceiling on cost per call\n| `ring_duration_ms` | `30000` | Stop ringing before most voicemail systems answer\n| `begin_message_delay_ms` | `1000` | Wait for the callee to finish saying \"hello\"\n| `allow_user_dtmf` | `true` | IVR navigation, with a 5 s keypress timeout\n| `is_public` | `false` | Agents are never publicly listed\n|===\n\n`GET /api/retell/voices` proxies Retell's voice catalogue for the agent editor.\n\n=== Agent definitions in the repository\n\n`scripts/retelai/agents/<name>/` holds each agent's `agent.md` (the prompt, in version control)\nand `index.ts` (its config). `scripts/retelai/sync.ts` pushes these definitions to Retell. Agents\ncurrently defined: `adrian`, `brian`, `cmon`, `kate`, `kathrine`.\n\n== Post-call analysis schema\n\n`DEFAULT_POST_CALL_ANALYSIS` in `lib/retellai/post-call-analysis.ts` defines the structured fields\nRetell extracts from each call — the shared vocabulary between the caller agent, the Retell\nplatform and the analysis prompt. Per-agent overrides are merged by `mergePostCallAnalysis`, so an\nagent can add or reshape fields without forking the base schema.\n\n== Regression test cases\n\n`AgentTestCase` captures a real conversation plus the *correct* answer, so a prompt change can be\nregression-tested before it goes live.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field group | Fields\n\n| Identity | `name`, `notes`, `sourceCallId` (null for hand-written cases)\n| Inputs | `transcript`, `recordingUrl`\n| Ground truth | `disposition`, `isAmazonSeller`, `aiSuspicionDetected`, `callScreeningDetected`, `isDecisionMaker`, `userSentiment`, `nextAction`, `callSuccessful`, `inVoicemail`\n|===\n\n=== Running a suite\n\n`POST /api/test-cases/run` invokes `TestCaseRunnerService`:\n\n. Skip any case with no transcript — there is nothing to evaluate (`skipped: true`).\n. Ask the model to predict the outcome from the transcript, given the caller agent version's\n  prompt and the formatted post-call analysis schema.\n. Parse the response with `PredictionSchema`, tolerating a fenced ```json block.\n. Compare field by field. A ground truth of `null` means \"not asserted\", producing\n  `pass: null` — that field is excluded from the verdict rather than counted as a pass.\n. `passed` is true only when *every evaluated field* passes.\n\nThe evaluated set is the intersection of `DEFAULT_POST_CALL_ANALYSIS` fields and test-case\nground-truth columns: `disposition`, `next_action`, `call_screening_detected`, `is_amazon_seller`.\nString comparison is case-insensitive; booleans are compared strictly.\n\n`POST /api/test-cases/suggest-prompt` proposes a prompt revision from failing cases.\n\n== Prompt suggestions from live issues\n\n`/agents/issues` surfaces the `issues` arrays produced by call analysis, filterable by `name`,\n`priority`, `agentName`, `agentVersion` and date range. Selecting an issue and calling\n`POST /api/ai-agents/issues/suggest-prompt` runs `PromptSuggestionService`, which proposes a\nrevision addressing it. The loop is: analyse calls → aggregate issues → suggest a prompt →\nregression-test it against saved cases → publish a new version.\n\n== Duplication\n\n`+POST /api/ai-agents/{id}/duplicate+` copies an agent and its latest version, giving operators a\nsafe way to experiment without editing a production agent.\n"},{"component":"dialer-v2","module":"domain","path":"domain:phone-numbers.adoc","title":"Phone numbers","description":"Outbound number pool, selection algorithm, purchase and release, SMS enablement.","url":"https://docx.payability.com/dialer-v2/domain/phone-numbers.html","navOrder":16,"source":"= Phone numbers\n:description: Outbound number pool, selection algorithm, purchase and release, SMS enablement.\n\n`PhoneNumbers` is the workspace's pool of outbound (dial-from) numbers. Do not confuse it with\n`TargetPhoneNumbers`, the registry of dial-*to* numbers — see xref:contacts.adoc#registries[Registries].\n\n== Number state\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Field | Default | Meaning\n\n| `isActive` | `true` | Available for selection\n| `isDeleted` | `false` | Soft-deleted; excluded from selection\n| `isReleased` | `false` | Returned to the carrier; `releasedAt` records when\n| `isDefault` | `false` | Marks a default number for the workspace\n| `smsEnabled` | `false` | Eligible as the `from` number for outbound SMS\n| `provider` | `external` | `external`, `retell_twilio` or `retell_telnyx`\n| `state` | `null` | US state code used for geographic matching\n| `maxAllowedCallsPerDay` | `null` | Daily cap; `null` means unlimited\n| `dailyCallCount` | `0` | Reset nightly by the Inngest function `phoneNumberDailyResetCron`\n| `totalCallCount` | `0` | Lifetime usage, never reset\n| `lastUsedAt` | `null` | Drives the release optimizer\n| `phoneNumberSid` | `null` | MessagingApp identifier\n| `retellPhoneNumberId` | `null` | Retell identifier after import\n| `costPerSecond`, `monthlyCost` | `null` | Cost tracking\n|===\n\n`phoneNumber` is globally unique, so a carrier number belongs to exactly one workspace.\n\n== Selection algorithm\n\n`PhoneNumberSelector.selectPhoneNumberForTarget(targetPhone, campaignId, subscriptionId, targetId)`\nis *state-first*: matching the callee's state matters more than matching their exact area code,\nbecause a familiar state code lifts pickup rates while an exact area-code match is rarely available.\n\n[mermaid]\n....\nflowchart TD\n  Start[\"selectPhoneNumberForTarget\"] --> ExtractState[\"Extract state from target number\"]\n  ExtractState -->|failed| Fallback\n  ExtractState --> ExtractArea[\"Extract area code (+1XXX)\"]\n  ExtractArea -->|failed| Fallback\n  ExtractArea --> A[\"Step A: numbers in the same state\\n(any area code)\"]\n  A --> FilterA[\"Filter: under daily cap\\nAND used < 1 time for this target\"]\n  FilterA -->|match| PickA[\"Random pick — isFallback: false\"]\n  FilterA -->|none| B[\"Step B: numbers with the same area code\"]\n  B --> FilterB[\"Same filter\"]\n  FilterB -->|match| PickB[\"Random pick — isFallback: false\"]\n  FilterB -->|none| Fallback[\"getFallbackPhoneNumber(campaignId)\"]\n  Fallback --> Any[\"Any available number, random\\nisFallback: true\"]\n  Any -->|pool empty| Pref[\"Campaign preferedPhone\"]\n....\n\n=== Eligibility filter\n\nApplied identically in steps A and B:\n\n. *Daily cap* — skip when `maxAllowedCallsPerDay` is set and `dailyCallCount >= maxAllowedCallsPerDay`.\n. *Per-target reuse* — `countCallsFromSourceToTarget(numberId, targetPhone)` must be below\n  `MAX_USES_PER_SOURCE_PER_TARGET`, which is `1`. A given source number therefore calls a given\n  target *once, ever*. Repeated calls from the same number to a number that did not answer look\n  like harassment to carrier spam filters and to the callee.\n. *Random pick* among survivors, spreading load across the pool.\n\n=== Fallback\n\n`getFallbackPhoneNumber(campaignId)` picks a random number from the whole available pool with\n`isFallback: true`. If the pool is empty it returns the campaign's `preferedPhone`, logging a\nwarning if that is also unset.\n\n[IMPORTANT]\n====\nPhone-number purchasing is currently *manual*. The selector no longer requests an automatic\npurchase and never skips a call for lack of a local number — it always falls back to any available\nnumber. `CallSkipReason.NO_PHONE_NUMBER_AVAILABLE` and the `skipForPurchase` branch remain in the\ncode for when automatic purchasing is re-enabled, and the body of the `purchasePhoneNumber` Inngest\nfunction is commented out.\n====\n\n== Purchase and release\n\n`PhoneNumberCallingService` and `lib/phone-service/` handle carrier lifecycle:\n\n* `purchasePhoneNumber()` buys a number through MessagingApp.\n* `registerWithRetell()` imports it into Retell using the provider's SIP trunk and termination URL.\n* `releasePhoneNumberComplete(id, number, retellPhoneNumberId)` removes it from Retell and returns\n  it to the carrier.\n\nCarrier settings live in one registry, `EXTERNAL_PROVIDER_CONFIGS` in `lib/constants.ts`:\n\n[source,typescript]\n----\nEXTERNAL_PROVIDER_CONFIGS = {\n  twilio: { label: 'Twilio', source: 'twilio', trunkSid: …, terminationUrl: … },\n  plivo:  { label: 'Plivo',  source: 'plivo',  trunkSid: …, terminationUrl: … },\n}\n----\n\n`EXTERNAL_PROVIDER_KEYS` orders them for UI dropdowns; `DEFAULT_EXTERNAL_PROVIDER` is `twilio`.\nAdding a carrier is a single new entry — the calling service, `MessagingAppPhoneService`, the\npurchase route and the Phone Numbers page all read from the registry.\n\nPurchase gating is controlled by two `Configs` keys (`CONFIG_KEYS` in `lib/constants.ts`):\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Key | Effect\n\n| `purchase_new_phone_numbers` | Master switch for automatic purchasing\n| `daily_phone_number_purchase_limit` | Cap on purchases per day\n|===\n\nAutomatic purchasing requires *both* to be enabled.\n\n== Scheduled maintenance\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Job | Schedule | Effect\n\n| `phoneNumberDailyResetCron` (Inngest) | Daily 00:00 UTC | `resetDailyCounts()` zeroes `dailyCallCount` on every number\n| `phoneNumberOptimizerCron` (Inngest) | Monday 11:15 UTC | `findReleasableNumbers(3, 10)`, then `releasePhoneNumberComplete` per number unused for 3+ days with fewer than 10 lifetime calls\n|===\n\nThe optimizer's parameters are narrower than its defaults (`30` days, `10` calls) suggest — it is\ninvoked with `3` days. Each release is its own Inngest step and passes the number's `provider`, so\nRetell-managed numbers are deleted from Retell only, and external numbers are also returned to the\ncarrier.\n\n== SMS\n\nA number with `smsEnabled = true` can be selected as the `from` address for outbound SMS.\n`CallRepository.findAvailableSmsPhoneNumber(subscriptionId)` picks one regardless of provider, and\n`RetellClient.createSmsChat()` starts the conversation with the fixed agent\n`RETELL_SMS_AGENT_ID` (`agent_dc036c59f7365da0f463b16e72`). Operators toggle the flag from the SMS\nicon on the Phone Numbers page. See xref:api:mcp.adoc#send-sms[the `send_sms` MCP tool].\n\n== Blocked numbers\n\n`BlockedPhoneNumbers` is a per-workspace deny list consulted by the dialer's DNC guard. It is\nseparate from `TargetPhoneNumbers.isBlocked`: the registry flag blocks a *known* contact endpoint,\nwhile this table blocks a raw number that may never have been a target.\n\n== Reporting\n\n`GET /api/reports/phone-number-pickup-rate` compares pickup rates per number, filterable by\n`state` and `provider` — the input to decisions about which numbers to keep, rest or release. See\nxref:reporting.adoc[].\n"},{"component":"dialer-v2","module":"domain","path":"domain:email-campaigns.adoc","title":"Email campaigns","description":"Email campaign configuration, the broadcast loop, the send workflow and content variations.","url":"https://docx.payability.com/dialer-v2/domain/email-campaigns.html","navOrder":17,"source":"= Email campaigns\n:description: Email campaign configuration, the broadcast loop, the send workflow and content variations.\n\nAn email campaign is a `Campaign` row with `type = email`. It shares the table and status enum\nwith call campaigns but uses a separate recipient model (`EmailCampaignTarget`) and its own\nconfiguration columns.\n\n== Configuration\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Field | Default | Meaning\n\n| `status` | `draft` | Only `running` broadcasts. `completed` is set from `paused` only and is not broadcast\n| `description` | `null` | Long-form description, also used as AI context\n| `goal` | `null` | Short goal statement given to the variation generator\n| `schedulerIntervalSeconds` | `30` | `pg_cron` interval\n| `emailsPerHour` | service default | Maximum enqueued per tick\n| `maxEmailsPerDay` | `null` (unlimited) | Hard daily cap across all ticks\n| `emailsPerVariationPerHour` | `100` in the send workflow | Rolling 60-minute cap per variation\n| `emailWindowStart` / `emailWindowEnd` | `null` | Allowed sending window\n| `emailAllowWeekends` | `false` | Whether weekend sends are allowed\n| `resendTopicId` | `null` | Resend topic for audience segmentation and platform unsubscribe\n| `templateId` | `null` | The campaign-less `EmailVariation` this campaign was seeded from\n| `segmentId` | `null` | Optional audience segment this campaign is assigned to (label + import starting point; not a live sync)\n|===\n\n== The broadcast loop\n\n`+POST /api/email-campaigns/{id}/broadcast+` is internal-only and gated by\n`verifyInternalSecret()`. `EmailCampaignService.processBroadcast(campaignId)` runs one tick.\n\n[mermaid]\n....\nflowchart TD\n  Cron[\"pg_cron every schedulerIntervalSeconds\"] --> Route[\"POST /api/email-campaigns/{id}/broadcast\"]\n  Route --> Guard{\"type = email\\nand status = running?\"}\n  Guard -->|No| Halt1[\"Return throttled\"]\n  Guard -->|Yes| Daily{\"countSentToday >= maxEmailsPerDay?\"}\n  Daily -->|Yes| Halt2[\"Return throttled — daily cap\"]\n  Daily -->|No| Batch[\"batchSize = min(emailsPerHour, remaining daily cap)\"]\n  Batch --> Lock[\"fetchAndLockPendingTargets(campaignId, batchSize, 2-day lock)\"]\n  Lock --> Empty{\"Any targets?\"}\n  Empty -->|No| Idle[\"Return — no pending recipients\"]\n  Empty -->|Yes| Var[\"selectVariation(campaign)\"]\n  Var --> Dom[\"selectSendingDomains(campaign)\"]\n  Dom --> Enqueue[\"Per target: random account,\\nbuild unsubscribe token,\\ninngest.send email-campaign/send.requested\"]\n....\n\n=== Target locking\n\n`fetchAndLockPendingTargets` leases a batch with one atomic statement, built by\n`leasePendingTargetsSql`:\n\n[source,sql]\n----\nWITH picked AS (\n  SELECT ect.id\n  FROM email_campaign_targets AS ect\n  INNER JOIN target_emails AS te ON te.id = ect.\"emailId\"\n  WHERE ect.\"campaignId\" = $1\n    AND ect.status = 'pending'\n    AND (ect.\"queuedAt\" IS NULL OR ect.\"queuedAt\" < $lockCutoff)\n    AND te.\"newsletterOptOut\" = false\n    AND te.\"isBlocked\" = false\n    AND (te.\"newsletterLastSentAt\" IS NULL\n         OR te.\"newsletterLastSentAt\" <= NOW() - (COALESCE(te.\"newsletterGapDays\", 7) * INTERVAL '1 day'))\n  ORDER BY ect.\"createdAt\" ASC\n  LIMIT $limit\n  FOR UPDATE OF ect SKIP LOCKED\n)\nUPDATE email_campaign_targets AS u\nSET \"queuedAt\" = $now\nFROM picked\nWHERE u.id = picked.id\nRETURNING u.id, u.\"createdAt\"\n----\n\nIt then loads the leased targets with their contacts, outside any transaction, and re-sorts them by\n`createdAt` because `RETURNING` order is unspecified.\n\n* There is no Prisma interactive transaction. Those time out after 5 seconds, and on large\n  campaigns the lease query alone used to exceed that, failing the tick with `P2028`.\n* `FOR UPDATE … SKIP LOCKED` means two concurrent ticks take disjoint batches instead of blocking.\n* The index `(campaignId, status, createdAt)` lets Postgres walk pending rows in send order and stop\n  at the batch limit, instead of sorting every pending row in the campaign.\n* The lock window is `EMAIL_TARGET_LOCK_WINDOW_SECONDS` (2 days). A target becomes eligible again\n  only once its `queuedAt` is older than that, which prevents duplicate events while the Inngest\n  send job is still in flight.\n* A tick that leases targets and then does not enqueue them gives the leases back with\n  `releaseTargetQueueLocks`. That happens when variation selection or sending-account selection\n  halts the run, or when `inngest.send` throws for a target. Without this, a run throttled on the\n  daily cap would hide a batch of recipients for 2 days.\n* Suppression and newsletter cadence are joined in at *lease* time, so ineligible recipients never\n  reach the queue.\n* `ORDER BY createdAt ASC` makes sending fair: earliest imported recipients go first.\n\n=== Newsletter cadence gap\n\n`TargetEmails.newsletterLastSentAt` and `newsletterGapDays` (default `7`) enforce a *cross-campaign*\nminimum gap between marketing emails to the same address. Without this, an address enrolled in\nthree campaigns would receive three emails the same morning. `isNewsletterSendEligible` implements\nthe check, and `email-send` bumps `newsletterLastSentAt` after a successful hand-off.\n\n=== Variation selection\n\n`selectVariation(campaign)`:\n\n. Load active variations and each one's send count over the last rolling hour.\n. Keep those under `emailsPerVariationPerHour`; pick one at random.\n. If *no* active variations exist, halt with `throttled: false` and the message\n  \"No active email variations found\" — a configuration problem that will not resolve by waiting.\n. If all active variations are over the rate:\n.. count *all* variations for the campaign. At `MAX_VARIATIONS = 5` or more, halt with\n   `throttled: true` and wait for the rate window to reset.\n.. below the limit, call `EmailVariationService.generateVariation(campaignId, subscriptionId)`\n   inline to write a new AI variation, then halt with `throttled: true`. The next tick picks it up.\n\nNote that generation is *synchronous within the broadcast tick* — it is not dispatched as an\nInngest event.\n\n`EmailVariation.usageCount` is a lifetime statistic. It never gates selection; only the rolling\nhourly count does.\n\n=== Sending account selection\n\n`selectSendingDomains(campaign)` calls `findEligibleSendingDomains(subscriptionId)`, which returns\nactive accounts under *both* their own `EmailSendingDomain.dailyCap` and their parent\n`Domain.dailyCap`. The broadcaster then picks one at random per target.\n\nAn empty pool is disambiguated with a second lookup, `findActiveSendingDomains`, which applies the same\n\"active account on an active domain (or no domain)\" filter but ignores caps, so the operator gets an\nactionable message:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Situation | `throttled` | Message\n\n| No active account on an active domain | `false` | \"No active email accounts on an active domain. Add or activate a sending domain to start.\"\n| Active accounts, all over cap | `true` | \"All sending accounts/domains have reached their daily cap. Sending resumes after the daily reset.\"\n|===\n\nThe distinction matters: `throttled: false` tells the operator that waiting will not help.\n\nSee xref:email-deliverability.adoc[] for the cap model.\n\n== The send workflow\n\n`emailSend` (`retries: 2`, `concurrency: { limit: 20 }`) reacts to\n`email-campaign/send.requested`.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| # | Step | Behaviour\n\n| 1 | `check-already-sent`\n| Refuse if the target is missing, or its status is any of `requested`, `sent`, `opened`,\n`clicked`, `unsubscribed`, `bounced`, `blocked`, `suppressed`, `failed`. This is the primary duplicate-send guard.\n\n| 2 | `check-eligibility`\n| One read of `target_emails` through the target. Suppressed (`newsletterOptOut` or\n`marketingOptOut` or `isBlocked`) → set target `unsubscribed` and stop. Cadence gap not yet elapsed\n→ clear the queue lock and stop, so the row can be re-leased later.\n\n| 3 | `check-send-limits`\n| Re-check the campaign daily cap and the assigned variation's hourly rate. Daily cap reached →\nrelease the lock and return `daily_limit_reached`. If the assigned variation is exhausted, fall\nback to another active variation under the rate. If none is available, release the lock and\nreturn `variation_rate_limited`.\n\n| 4 | `send-email`\n| Resolve template variables from `contactData`, applying each variable's `fallbackValue` when the\ncontact field is empty, and coercing `number`-typed variables. `UNSUBSCRIBE_LINK` is set *last* so\nno user-defined variable can overwrite it. Send through Resend with the resolved `from`, `replyTo`,\nsubject, HTML, optional `resendTemplateId` and `topicId`, and a `target_id` tag carrying the\ntarget id.\n\n| 5 | `update-target`\n| `markTargetRequested` stores `resendEmailId`, `variationId`, `sendingDomainId` and `sentAt`, and\nmoves the status to `requested` only while it is still `pending`, so a webhook that already\narrived is not overwritten; bump `target_emails.newsletterLastSentAt`; increment the sending account's `dailySentCount`;\nincrement the variation's `usageCount`.\n\n| 6 | `log-activity-and-crm`\n| Best-effort: write an `Activity` row (type `email`, body truncated to 500 chars) and publish\n`Crm.NewActivity` when the contact has a HubSpot id. Failures are logged, not fatal.\n|===\n\nLimits are re-checked in step 3 even though the broadcaster already checked them, because minutes\ncan pass between leasing and sending and other campaigns may have consumed the budget in between.\n\n`sentAt` records the successful hand-off to Resend, and is later refreshed by the\n`email.delivered` webhook.\n\n=== Matching webhooks to targets\n\n`send-email` and `update-target` are separate steps, so Resend can deliver `email.sent`,\n`email.delivered`, `email.opened` or `email.suppressed` before `resendEmailId` is saved. Before\nrunning a handler, `ResendWebhookDispatcher` calls `resolveResendWebhookTarget`\n(`lib/events/resend-target-resolution.ts`):\n\n. A target already holding the event's `email_id` is used as is.\n. Otherwise the `target_id` tag names the target. Its `resendEmailId` is claimed only while it is\n  still null. A target already linked to a different id is never re-pointed.\n. Without a usable tag, `data.template_id` is matched to `EmailVariation.resendTemplateId`, and the\n  recipient address to a queued target in one of those campaigns whose `resendEmailId` is null. The\n  claim happens only when exactly one target matches.\n\nResolution failures are logged and the handler still runs, so an unmatched event is a no-op.\n\nHandlers only move status forward. `email.sent` and `email.delivered` promote `pending` or\n`requested` to `sent`; `email.opened` promotes `pending`, `requested` or `sent` to `opened` and sets\n`openedAt` regardless of status; `email.bounced` and `email.unsubscribed` never overwrite `blocked`\nor `suppressed`. A late `email.delivered` therefore no longer downgrades an `opened` target.\n\n== Content variations\n\n`EmailVariation` holds a subject and a full HTML body.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `campaignId` | The owning campaign, or `NULL` for a reusable template\n| `name`, `subject`, `htmlBody` | Content\n| `resendTemplateId` | Resend template created programmatically; synced on create and update\n| `templateType` | Always `\"custom\"` — Resend templates are the canonical store\n| `templateVariables` | Array of `{ key, type, fallbackValue }`; keys map to contact fields at send time\n| `status` | `pending_generation`, `active` or `archived`\n| `usageCount` | Lifetime send count\n|===\n\n=== Templates versus campaign variations\n\nA row with `campaignId = NULL` is a *template*, managed at `/email-campaigns/variations`. Templates\nare database-only — they have no Resend template — until they are copied into a campaign, at which\npoint the campaign POST path creates a normal variation with a Resend template. Every selection and\nsend query filters by `campaignId`, so a template can never be sent by accident.\n\n=== AI generation\n\n`EmailVariationService` rephrases an existing variation's HTML body through the Vercel AI Gateway,\ncreates a matching Resend template, and saves the result as a new active variation. It is triggered\nwhen every active variation is over its hourly rate and the campaign has fewer than five\nvariations. `lib/services/email-variation-sqs.ts` provides an SQS-based alternative path.\n\n== Unsubscribe (RFC 8058)\n\nEvery outgoing email carries `List-Unsubscribe` and `List-Unsubscribe-Post` headers pointing at\n`/api/unsubscribe`, plus an in-body link using the same URL. The token is the `EmailCampaignTarget`\nid encoded as base64url.\n\n`GET` renders a confirmation page; `POST` performs the one-click opt-out. A successful unsubscribe\nsets `TargetEmails.newsletterOptOut` with its timestamp, which suppresses the address across every\ncampaign in the workspace. Resend topics provide a second, platform-level unsubscribe surface.\n\n== Duplication\n\n`+POST /api/email-campaigns/{id}/duplicate+` emits `email-campaign/duplicate.requested`, handled by\nthe `emailCampaignDuplicate` Inngest function. Copying a campaign means copying its variations,\nrecreating their Resend templates and re-resolving its recipients — too much work for one request,\nso it runs as a durable workflow. See xref:workflows:email.adoc[].\n\n== Recipient import\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Endpoint | Behaviour\n\n| `+POST /api/email-campaigns/{id}/targets+` | Import from an uploaded list; contacts are upserted first, so `EmailCampaignTarget.contactId` is always set\n| `+POST /api/email-campaigns/{id}/targets/from-registry+` | Import from the `TargetEmails` registry by filter\n| `+GET /api/email-campaigns/{id}/targets/from-registry/resolve+` | Preview how many registry rows a filter would import\n| `+POST /api/email-campaigns/{id}/targets/from-segment+` | Queue a one-shot Inngest copy of a segment's current active members (`202`) and persist `Campaign.segmentId`. Same chunked `INSERT…SELECT` pattern as duplication. Members without a local Contact + email + sendable `target_emails` row are skipped. Optional `segment_id` on `POST /api/email-campaigns` stores the assignment and starts the same job after create.\n| `+GET /api/email-campaigns/{id}/targets/export+` | Export recipients with their current status\n|===\n\n`EmailCampaignTarget` is unique on both `(campaignId, contactId)` and `(campaignId, email)`, so the\nsame person cannot be enrolled twice in one campaign but can be a recipient of many campaigns.\n\n== Recipient statuses\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Status | Set by\n\n| `pending` | Import. Eligible for the next tick\n| `requested` | `emailSend` after Resend accepted the message\n| `sent` | The `email.sent` or `email.delivered` webhook\n| `opened` | The `email.opened` webhook\n| `bounced` | The `email.bounced` webhook\n| `unsubscribed` | The `email.unsubscribed` webhook, or the eligibility step finding suppression\n| `blocked` | The `email.complained` webhook (spam complaint)\n| `suppressed` | The `email.suppressed` webhook: Resend dropped the send because the address is on its suppression list (so far always `previous_bounce`). The address is also blocked in `target_emails` with `blockReason` `resend_suppressed:<reason>`\n| `failed` | A send error that exhausted retries\n|===\n\n[NOTE]\n====\nThere is no `clicked` status in the `EmailTargetStatus` enum. A click is recorded as a\n`clickedAt` timestamp on the target and as `clickedCount` on the sending account, while the status\nstays at whatever the delivery lifecycle last set. Filter on `has_clicked` rather than on status.\n====\n\n`EmailCampaignTarget.category` (`EmailTargetCategory`) holds the LLM classification of a *reply*:\n`interested`, `not_interested`, `question`, `out_of_office`, `unsubscribe`, `wrong_person`,\n`other`. See xref:email-inbox.adoc[].\n\n== Campaign statistics\n\nList and detail counts come from the `v_email_campaign_stats` materialized view\n(`lib/supabase/views/v_email_campaign_stats.sql`). `pg_cron` job\n`refresh-v_email_campaign_stats` runs `REFRESH MATERIALIZED VIEW CONCURRENTLY` at the top of every\nhour, so counts can lag until that refresh. Campaign name, status, and settings are read from the\nlive `Campaign` row, so creates and edits show up immediately. A campaign created after the last\nrefresh has no view row yet; the API treats that as zeros.\n\nThe view also snapshots description, send limits, window, template, and segment. The API does not\nread those columns from the view.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Field | Composition\n\n| `total_count` | every target on the campaign\n| `valid_count` | targets whose registry row is not opted out and not blocked\n| `pending_count` | `pending`, not opted out, not blocked in the registry, and `queuedAt` unset or older than the 2-day lock window\n| `queued_count` | `pending`, not opted out, not blocked, and `queuedAt` within the 2-day lock window\n| `requested_count` | status `requested` (accepted by Resend, no `email.sent` or `email.delivered` webhook yet); also counted in `delivered_count`\n| `opened_count` | `sentAt` is set and status is `opened` or `unsubscribed`\n| `bounced_count` | status `bounced`\n| `suppressed_count` | status `suppressed`\n| `unsubscribed_count` | status `unsubscribed`\n| `blocked_count` | status `blocked`, plus `pending` targets whose `target_emails` row is blocked (a hard bounce or complaint on any earlier campaign)\n| `sent_count` | `sentAt` is set, any status\n| `delivered_count` | `sentAt` is set and status is `requested`, `sent`, `opened`, or `unsubscribed`\n| `clicked_count` | `clickedAt` is set on a target counted in `delivered_count`\n|===\n\n`pending_count` and `queued_count` do not overlap. The 2-day interval in the view must match\n`EMAIL_TARGET_LOCK_WINDOW_SECONDS` in `lib/services/email-campaign-service.ts`. Because the view\nrefreshes hourly, a lease can show as queued for up to an hour after it expires.\n\n`sent_count` follows the timestamp rather than status: a recipient who later opens or bounces still\nhas `sentAt` set. A click does not change status, so `clicked_count` combines `clickedAt` with the\ndelivered statuses. A complaint is stored as `blocked`, so it appears in `blocked_count` and not in\n`delivered_count`.\n\n`requested` counts as delivered because not every send receives an `email.sent` or\n`email.delivered` webhook. The send workflow (`lib/workflows/functions/email-send.ts`) sets\n`requested` and `sentAt` together once Resend accepts the email. Without a webhook the target stays\n`requested`, and excluding it would leave it out of the open and click rate denominators. An open\nstill moves it to `opened`, but a click does not change status, so its click is counted only\nbecause `requested` is a delivered status. `vm_domain_email_stats` uses the same rule.\n\nAn unsubscribe counts as an open: the recipient had to open the email to click the link, and\nResend counts it the same way. A `suppressed` target has `sentAt` set but was never delivered, so it\nis in `sent_count` and not in `delivered_count`. The same opened and delivered rules apply in\n`vm_domain_email_stats`, `vm_email_campaign_target_summary` (`total_opened`, `total_suppressed`),\n`v_email_campaign_daily_engagement` and the variation performance SQL.\n\nThe campaign Analytics tab and the campaigns list divide opened and clicked by `delivered_count`,\nand the Analytics tab divides bounced by `sent_count`.\n\n=== Performance tab\n\nThe Performance tab on the campaign detail page compares variations. It reads\n`GET /api/email-campaigns/[id]/variation-performance`, which queries `email_campaign_targets` live\nfor one campaign, so its numbers do not wait for the hourly refresh. The route resolves the\nsubscription, returns 404 unless the campaign is an email campaign in that subscription, and the\nrepository SQL (`variationPerformanceSql`, `variationDailyPerformanceSql` in\n`lib/repositories/email-campaign-repository.ts`) filters on both `subscriptionId` and `campaignId`.\n\nOnly targets with `sentAt` set are counted, grouped by `variationId`. Sent, delivered (including\n`requested`), opened, clicked, and bounced use the same definitions as the table above, so summing\nthe variations matches the Analytics tab once the view has refreshed. Targets without a\n`variationId` appear as \"Unassigned\". Archived variations are included because they carry\nhistorical sends.\n\nOpen and click rates are a percentage of delivered; bounce rate is a percentage of sent. Rates are\nrounded to one decimal and are null when the denominator is zero (`lib/reports/email-variation-performance.ts`).\nA variation with fewer than `MIN_DELIVERED_FOR_COMPARISON` (50) delivered emails is flagged as low\nsample: its bubble is faded and it cannot be highlighted as the best open or click rate. A best\nrate is only highlighted when at least two variations have enough data.\n\nThe rates chart is a bubble scatter: open rate on the x axis, click rate on the y axis, bubble size\nby delivered emails, and dashed reference lines at the campaign-wide open and click rate. Bounce\nrate appears in the bubble tooltip, the trend chart's Bounce toggle, and the comparison table. Each\nvariation keeps one color across both charts and the table (`components/email-campaign/variation-colors.ts`).\n\nThe daily trend groups sends into cohorts by the America/New_York day of `sentAt`. Each point is the\nrate for emails a variation sent that day, wherever the open or click happened later, so a point\nnever exceeds 100%. Recent days rise as late opens arrive. The optional `dateFrom` and `dateTo`\nquery parameters (`YYYY-MM-DD`, inclusive) bound the same sent day for both the comparison and the\ntrend.\n\n=== Re-engagement tab\n\nThe Re-engagement tab follows everyone who clicked the campaign through the re-engagement\nprocess. It reads `GET /api/email-campaigns/[id]/reengagement-funnel`. The route resolves the\nsubscription and returns 404 unless the campaign is an email campaign in that subscription. The\nservice (`lib/services/email-click-reengagement-funnel-service.ts`) runs one BigQuery query,\n`emailClickReengagementFunnelSql` in `lib/bigquery/client.ts`, because re-engagement lists and\nHubSpot calls only exist in BigQuery. The query filters the `outreach_app.email_campaign_targets`\nmirror on both `subscriptionid` and `campaignid`, keeps the latest row per target, and binds every\nvalue as a parameter.\n\nEach count is unique people, keyed by the recipient's lowercased email. Every stage only counts what\nhappened on or after the click:\n\n[cols=\"1,3\"]\n|===\n|Stage |Definition\n\n|Clicked\n|Targets with `clickedat` set. The click day is the America/New_York day of the person's first click.\n\n|On a re-engagement list\n|At least one `outreach_app.reengagement_daily_list_entries` row (any segment) for a matched HubSpot\ncontact, with a list date on or after the click day.\n\n|Called by a rep\n|At least one `hubspot4.v_calls_latest` call for a matched HubSpot contact from a human owner (not the\nAI dialer owner `86817215`), on or after the first list date after the click.\n\n|Connected\n|The person's best call disposition is in `REENGAGEMENT_CONVERSATION_DISPOSITION_IDS`, the same\nconversation set as the re-engagement daily report.\n|===\n\nClickers are matched to HubSpot by email through `hubspot4.v_contacts_latest`, plus the mirrored\n`outreach_app.contacts.hubspotcontactid`. The mirrored id alone is often null or stale (see\n`lib/bigquery/v_email_clicked_re_engagement_summary.sql`). Clickers with no match are counted as not\non a list and reported as `unmatched_hubspot`.\n\nEach called person is placed under one disposition: the best one across all their qualifying rep\ncalls, ranked by `CLICK_FUNNEL_RANKED_DISPOSITIONS` in `lib/reports/email-click-reengagement-funnel.ts`.\nThe 13 conversation dispositions rank first, from Account Created down to Hang up, so connected means\na rank below 13. Not interested and Do not contact are not in the conversation set, so they appear\nunder not connected. Unknown dispositions count as \"Other disposition\" and empty ones as \"No\ndisposition\".\n\nThe data comes from the BigQuery mirror, so recent clicks and calls can lag the live tabs. The client\ncaches the response for five minutes; the Refresh button runs the query again.\n\n==== People list\n\nThe People card under the funnel lists the clickers themselves. It reads\n`GET /api/email-campaigns/[id]/reengagement-funnel/people?stage=&deal=&search=&page=&pageSize=`,\nwith the same subscription and email-campaign checks as the funnel route. The response uses the\nstandard pagination shape `{ data, total, page, pageSize }`; `pageSize` is 1 to 100 (default 25).\n\nThe funnel and the list share one BigQuery CTE, `emailClickFunnelPeopleCte` in\n`lib/bigquery/client.ts`, which builds one row per clicked email. The funnel groups those rows and\nthe list pages through them, so a filtered list always adds up to the matching funnel stage. Rows are\nordered by first click, newest first.\n\n[cols=\"1,3\"]\n|===\n|Filter |Values\n\n|`stage`\n|`all`, `not_re_engaged` (not on a list, including no HubSpot match), `not_called` (on a list, not\ncalled), `called`, `connected`, `not_connected`. Clicking a funnel stage card sets this filter.\n\n|`deal`\n|`all`, `has_deal`, `no_deal`.\n\n|`search`\n|Case-insensitive substring of email, full name, or company. Trimmed, at most 100 characters.\n|===\n\nEach person shows a primary HubSpot contact id for the contact link. It is the first of: an id that\nput the person on a re-engagement list, the mirrored `outreach_app.contacts.hubspotcontactid`, then\nthe most recently updated email match. When several ids match, the row shows how many more exist.\n\nThe deal column shows any non-deleted `hubspot4.v_deals_latest` deal associated with any of the\nperson's matched ids, not only deals created after the click. When there are several, open deals win\nover closed ones, then the newest. The stage label and won, pending, or lost outcome come from\n`lib/reports/ai-calling-deal-stages.ts`. The before or after click flag compares the deal's creation\nday (UTC) with the click day.\n"},{"component":"dialer-v2","module":"domain","path":"domain:email-deliverability.adoc","title":"Email deliverability","description":"Sending domains, email accounts, the four-level cap model, suppression and per-account statistics.","url":"https://docx.payability.com/dialer-v2/domain/email-deliverability.html","navOrder":18,"source":"= Email deliverability\n:description: Sending domains, email accounts, the four-level cap model, suppression and per-account statistics.\n\nDeliverability is managed through a two-level sender hierarchy and four independent send caps.\n\n== Sender hierarchy\n\n[mermaid]\n....\nflowchart TD\n  Sub[\"Subscription\"] --> D1[\"Domain\\npayability.com\\ndailyCap 2000\"]\n  Sub --> D2[\"Domain\\nsales.payability.com\\ndailyCap 1000\"]\n  D1 --> A1[\"EmailSendingDomain\\nalex@payability.com\\ndailyCap 300\"]\n  D1 --> A2[\"EmailSendingDomain\\nsam@payability.com\\ndailyCap 300\"]\n  D2 --> A3[\"EmailSendingDomain\\njo@sales.payability.com\\ndailyCap 250\"]\n....\n\n`Domain`::\nOne row per Resend-verified domain, unique on `(subscriptionId, name)`. Holds `resendDomainId`,\n`resendStatus`, a shared `dailyCap`, a `warmupStatus` label (`warming_up` or `active`), `isActive`,\nand a `totalSentCount` that is never reset. The domain's usage today is the *sum of its accounts'*\nsends today — it has no counter of its own.\n\n`EmailSendingDomain` (\"email account\" in the UI)::\nA concrete `From` address under a parent domain. Holds `fromName`, `fromEmail`, `domain`,\n`resendDomainId`, `isActive`, `isVerified`, its own `dailyCap`, `dailySentCount` and `dailySentDate`, a lifetime\n`totalSentCount`, and per-event counters. `domainId` is nullable because it was added after the\ntable existed and legacy rows are backfilled by hand.\n\n[NOTE]\n====\nSubdomains are not modelled as children of their parent domain. In Resend each subdomain is its own\nverified domain with its own domain id, so `sales.payability.com` is a separate `Domain` row. An\naccount's `fromEmail` host always matches its parent `Domain.name` exactly.\n====\n\n== The four caps\n\nChecked in this order; the first breach stops the send.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Level | Field | Enforcement\n\n| Campaign per day\n| `Campaign.maxEmailsPerDay`\n| `countSentToday(campaignId)` at the top of the broadcast, and again in `emailSend` step 3\n\n| Variation per hour\n| `Campaign.emailsPerVariationPerHour`\n| `countSendsPerVariationInLastHour(campaignId)` — a rolling 60-minute count, not a clock hour.\nWhen breached, the broadcaster picks a different variation or triggers AI generation; `emailSend`\nfalls back to another eligible variation or releases the lock\n\n| Account per day\n| `EmailSendingDomain.dailyCap`\n| `findEligibleSendingDomains` filters the pool; `dailySentCount` is incremented at Resend\nhand-off, not on the webhook, so the cap reflects committed sends immediately\n\n| Domain per day\n| `Domain.dailyCap`\n| Same query, comparing the sum of the domain's active accounts' sends today against the parent cap\n|===\n\n`null` means unlimited at every level.\n\nRolling windows are used for the per-variation rate rather than clock hours because a clock-hour\nreset produces a burst at the top of every hour, which is exactly the pattern spam filters look for.\n\n=== Date-scoped daily counters\n\n`dailySentCount` only counts for the UTC day stored in `dailySentDate`. `incrementSendingDomainDailyCount`\ndoes one atomic `UPDATE`: when `dailySentDate` is today it adds 1, otherwise it restarts the counter at 1\nand stamps today's date. Every reader goes through `effectiveDailySent()` in\n`lib/utils/email-daily-cap.ts`, which treats a counter from an earlier day as 0:\n\n* `findEligibleSendingDomains`, for both the account cap and the domain total\n* `getDomainSummary` (`sentToday`) and the email-domain serializers (`daily_sent_count`, `daily_sent_total`)\n\nSo a missed reset can no longer leave accounts stuck over their cap.\n\n=== Daily reset\n\nThe Inngest function `sendingDomainDailyResetCron` runs at 00:00 UTC, zeroes `dailySentCount` and clears\n`dailySentDate` on every account. It is a backstop; the date-scoped counters already roll over on their\nown. `totalSentCount` on both the account and its parent domain is never reset.\n\n=== Daily capacity\n\nThe Sending page's daily capacity (`getDomainSummary.dailyCapacity`) adds, for each active domain, the\nlower of `Domain.dailyCap` and the sum of its active accounts' caps. A domain with a 3,000 cap but one\n1,000-cap account contributes 1,000. A domain where both sides are unlimited contributes 0.\n\n== Counters\n\nIncremented by the Resend webhook handlers (see xref:events:resend.adoc[]):\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Counter | Incremented on | Notes\n\n| `dailySentCount` | Resend hand-off in `emailSend` | Counts only for `dailySentDate` (UTC); drives the daily cap\n| `totalSentCount` | Resend hand-off in `emailSend` | Lifetime, never reset\n| `sentCount` | `email.sent` | Lifetime provider-confirmed sends\n| `receivedCount` | `email.received` | Inbound replies to this account\n| `openedCount` | first `email.opened` per target | Deduplicated by `openedAt` being null\n| `clickedCount` | first `email.clicked` per target | Deduplicated by `clickedAt` being null\n| `bouncedCount` | `email.bounced` |\n| `unsubscribedCount` | `email.unsubscribed` |\n|===\n\n`email.delivered` deliberately does *not* increment `sentCount` — `email.sent` already did, and\ncounting both would double every send.\n\nOpens and clicks are counted only on their first occurrence per recipient, so the numbers are\nunique-recipient counts rather than raw event counts.\n\n== Suppression\n\nSuppression lives on `TargetEmails`, the per-workspace address registry — not on a separate\nsuppression table, and not per campaign. An address is suppressed when *any* of these is true:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Flag | Set by\n\n| `newsletterOptOut` (+ `newsletterOptOutAt`)\n| `/api/unsubscribe` (RFC 8058 one-click or hosted link), or the `email.unsubscribed` webhook\n\n| `marketingOptOut` (+ `marketingOptOutAt`)\n| The `email.complained` webhook (spam complaint)\n\n| `isBlocked` (+ `blockReason`, `blockedAt`)\n| Permanent `email.bounced` (hard bounce: the address is invalid) and `email.complained` (spam). Complaints also set marketing and newsletter opt-out and store `blockReason` `Spam complaint`. Hard bounces do not change opt-out flags. Transient and Undetermined bounces are recorded on the campaign target and leave the address sendable\n|===\n\n`oneToOneOptOut` exists for direct one-to-one email and is not part of the campaign suppression\ncheck.\n\nSuppression is enforced twice:\n\n. *At lease time* — `fetchAndLockPendingTargets` joins `target_emails` and requires\n  `newsletterOptOut = false AND isBlocked = false`, so suppressed addresses never enter the queue.\n. *At send time* — `emailSend` step `check-eligibility` calls `getTargetEmailEligibility`. `isBlocked` sets the campaign target to `blocked` and copies `blockReason` into `lastError`. Newsletter or marketing opt-out, when the address is not blocked, sets `unsubscribed`.\n\nThe double check exists because minutes pass between the two, and a recipient may unsubscribe from a\ndifferent campaign's email in that window.\n\nA spam complaint sets `marketingOptOut` *and* `isBlocked`, which is stricter than an unsubscribe:\na complaint is a deliverability risk to the whole domain, not just a preference. A Permanent bounce\nsets only `isBlocked` plus `blockReason`, because the address cannot receive mail. The bounced\ncampaign row stays status `bounced` so deliverability charts do not count it as a complaint.\n\n== Resend topics\n\n`Campaign.resendTopicId` links a campaign to a Resend topic, giving audience segmentation and\nplatform-managed unsubscribe alongside the application's own. `lib/services/resend-topic-service.ts`\nmanages topics, and `emailSend` passes `topicId` on every send.\n\n== Reply-to\n\n`buildReplyToForSendingDomain({ fromEmail, fromName })` in `lib/utils/emails.ts` derives the\n`Reply-To` header from the sending account, so replies land on an address the inbox can correlate\nback to a campaign. See xref:email-inbox.adoc[].\n\n== Operating checklist\n\n* Verify the domain in Resend, then create the `Domain` row with its `resendDomainId`\n  (`GET /api/email-domains/resend-domains` lists verified domains).\n* Add at least one active, verified `EmailSendingDomain` under it.\n* Set `dailyCap` on both levels. During warm-up, raise the account caps gradually and leave\n  `warmupStatus` at `warming_up` as a signal to other operators.\n* Watch `bouncedCount` and `unsubscribedCount` per account — a single bad account can damage the\n  reputation of the whole domain.\n* `GET /api/email-domains/stats` and `GET /api/email-domains/summary` back the dashboard views.\n"},{"component":"dialer-v2","module":"domain","path":"domain:email-inbox.adoc","title":"Email inbox and replies","description":"Inbound reply capture, threading, categorisation and the in-app inbox.","url":"https://docx.payability.com/dialer-v2/domain/email-inbox.html","navOrder":19,"source":"= Email inbox and replies\n:description: Inbound reply capture, threading, categorisation and the in-app inbox.\n\nOutbound campaign email is tracked on `EmailCampaignTarget`. Individual *messages* — outbound and\ninbound — are stored in `Emails`, which backs the in-app inbox at `/emails`.\n\n== The `Emails` model\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `threadId` | Shared by every message in a conversation; a new UUID starts a new thread\n| `direction` | `EmailDirection` — sent or received\n| `externalId` | The Resend `email_id`; unique per `(subscriptionId, externalId)`\n| `inReplyToExternalId` | Parent message id, for reply correlation\n| `inboxCategory` | `EmailInboxCategory` — the in-app folder\n| `category` | `EmailTargetCategory` — the LLM classification, mirrored from the campaign target\n| `toEmail`, `fromEmail`, `subject`, `body`, `bodyHtml` | Content\n| `opened` | Set by the Resend `email.opened` pixel event\n| `readAt` | In-app read state — deliberately separate from `opened`\n| `archivedAt` | Archive state\n| `sendingDomainId` | Which account sent or received it\n| `contactId`, `campaignId`, `emailCampaignTargetId` | Correlation to the contact graph\n|===\n\n`opened` and `readAt` are separate because they answer different questions: `opened` is the\n*recipient's* pixel firing, `readAt` is whether *an operator* has read the reply.\n\n== Inbound correlation\n\nResend delivers replies as `email.received`. The `data.email_id` on that event is the *new* message\nid, not the outbound `resendEmailId` stored on the target, so the reply must be matched heuristically.\n\n`EmailReceivedHandler` (`lib/events/handlers/resend/email-received.ts`):\n\n. Strip `Re:` / `Fwd:` prefixes from the subject.\n. Match the cleaned subject against `EmailVariation.subject` among targets that share the sender's\n  address, falling back where the subject is missing.\n. Upsert an `Activity` row for the reply.\n. Increment `receivedCount` on the sending account.\n. When a target and a `from` address were both resolved, publish `Email.IncomingCategorise` to\n  `INCOMING_EMAIL_SQS_QUEUE_URL`:\n\n[source,json]\n----\n{\n  \"event_type\": \"Email.IncomingCategorise\",\n  \"campaign_id\": \"<uuid>\",\n  \"contact_email\": \"<reply-from>\",\n  \"inbound_subject\": \"…\",\n  \"inbound_text\": \"<plain text, HTML stripped>\"\n}\n----\n\n`htmlToPlainText` in `lib/repositories/emails-repository.ts` produces the plain-text body. A\ndownstream service classifies the reply and writes back `EmailCampaignTarget.category`,\n`categorisedAt` and `categoryModel`.\n\nCorrelating on subject rather than a message id is a compromise: it is why `Reply-To` is derived per\nsending account, narrowing the candidate set to targets that used that account.\n\n== Categories\n\n`EmailTargetCategory`: `interested`, `not_interested`, `question`, `out_of_office`,\n`unsubscribe`, `wrong_person`, `other`.\n\nThese drive triage. `interested` and `question` need a human; `out_of_office` should be retried\nlater; `wrong_person` and `unsubscribe` are suppression signals.\n\nRecipients can be filtered by category on the campaign targets endpoint, where `category=none`\nselects uncategorised recipients and `category=all` disables the filter.\n\n== The inbox\n\n`GET /api/emails` supports:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Parameter | Meaning\n\n| `page`, `page_size` | Pagination, max 100\n| `direction` | `all`, or a specific `EmailDirection`\n| `inbox_category` | `all`, or a specific `EmailInboxCategory`\n| `search` | Free text over subject, body and addresses\n| `include_archived` | `true` / `1` to include archived messages\n| `sending_domain` | `all`, `none` (no `sendingDomainId`), or an account UUID\n| `campaign_id` | Restrict to one campaign\n| `target_category` | `all`, or an `EmailTargetCategory`\n|===\n\nEach row returns `reply_count` (messages in the thread) and `is_read` (derived from `readAt`), plus\nthe resolved campaign name and contact name.\n\n`+GET /api/emails/{id}+` returns the full thread; `+PATCH /api/emails/{id}+` updates read, archive and\ncategory state. `POST /api/emails` sends a one-off email — used for manual replies — writing both an\n`Emails` row and an `Activity` row.\n\n== Contact-scoped views\n\n`+GET /api/contacts/{id}/emails+` lists a contact's messages and\n`+PATCH /api/contacts/{id}/emails/{emailId}+` updates one, powering the email section of the contact\ndetail page.\n"},{"component":"dialer-v2","module":"domain","path":"domain:sequences.adoc","title":"Nurture sequences","description":"Sequence templates, step channels, data gates, enrollment and durable execution.","url":"https://docx.payability.com/dialer-v2/domain/sequences.html","navOrder":20,"source":"= Nurture sequences\n:description: Sequence templates, step channels, data gates, enrollment and durable execution.\n\nA sequence is a templated multi-channel journey for one contact. `SequenceTemplate` plus its ordered\n`SequenceTemplateStep` rows define the journey; a `ContactSequence` is one contact travelling\nthrough it, driven end-to-end by a single durable Inngest run.\n\n== Templates\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `name`, `goal`, `description` | Human description; `goal` and `description` are also fed to the AI writer\n| `type` | `SequenceType` — `onboarding`, `re_engagement`, `not_connected_outreach`\n| `callCampaignDefault`, `emailCampaignDefault`, `smsCampaignDefault` | Marks this template as the default for that campaign kind\n| `isActive` | Only active templates are offered for enrollment\n|===\n\nEndpoints: `GET|POST /api/sequence-templates`,\n`+GET|PATCH|DELETE /api/sequence-templates/{id}+`,\n`+POST /api/sequence-templates/{id}/duplicate+`. The builder UI lives in `components/sequences/` and\n`app/(dashboard)/sequences/templates/`.\n\n== Step channels\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `channel` | Effect\n\n| `email` | Sends an email to the contact and records an `Activity`\n| `sms` | Starts a Retell SMS chat and records an `Activity`\n| `call_task` | Creates a `FollowUpTask` of type `call` for a sales rep\n| `wait` | A durable delay — the only channel that pauses the run\n| `data_gate` | Evaluates BigQuery conditions and branches, or waits and re-checks\n| `crm_task` | Publishes `Crm.TaskCreate` to the CRM queue\n| `crm_event` | Publishes a user-defined, template-interpolated event to the CRM queue\n|===\n\n=== Common step fields\n\n`stepIndex` (unique per template), `delayHours` with `delayUnit` (`hours` or `minutes`, meaningful\nonly for `wait`), `timeoutHours` (how long a rep has on a `call_task`), `name`, `description`,\n`instructions`, `expectedOutcome`, `emailTone` (`warm`, `direct`, `social_proof`, `urgency`,\n`breakup`), `emailSubjectHint`, `smsTemplate`, `isAiGenerated`, `fixedEmailId`.\n\nOnly `wait` steps pause. Action steps execute immediately when reached — pauses are modelled\nexplicitly rather than being implied by a per-step delay, which makes a template's timeline readable\nfrom its step list.\n\n== Action semantics\n\n[cols=\"1,3a\",options=\"header\"]\n|===\n| Channel | Behaviour and skip conditions\n\n| `email`\n| Subject is `emailSubjectHint`, else `name`, else `\"Following up\"`; body is `description`, else\n`instructions`. Logs to HubSpot when the contact has a `hubspotContactId` (best-effort) and writes\nan `Activity`. Skipped with `no_contact_email` when the contact has no address.\n\n| `sms`\n| Picks an SMS-enabled number for the workspace and starts a Retell chat with\n`RETELL_SMS_AGENT_ID`, passing `customer_name`, `customer_phone`, `company_name`, `goal` and\n`initial_message`. Skipped with `no_contact_phone` or `no_sms_number`.\n\n| `call_task`\n| Creates one `FollowUpTask` per *enrollment*: if a call task already exists for this\n`contactSequenceId`, the step is skipped with `already_called`. A sequence may loop back to a call\nstep without generating a second task. The rep is resolved by the contact's `hubspotOwnerId`,\nfalling back to the oldest active rep in the workspace; no rep at all skips with `no_sales_rep`.\n`dueAt` is `now + delayHours`.\n\n| `crm_task`\n| Publishes `Crm.TaskCreate` with `hubspotContactId`, `hubspotOwnerId`,\n`taskType` (`crmTaskType`, default `follow_up`), `description` and a `dueDate` of\n`now + crmTaskDueHours`. Skipped with `no_hubspot_contact` when the contact is not in HubSpot.\n\n| `crm_event`\n| Validates `extraConfigs` against `CrmEventConfigSchema`, interpolates the body, then publishes\nwith `event_type` forced from the config. Returns the exact published body so the Inngest step\nresult is self-documenting.\n|===\n\n== Data gates\n\nA `data_gate` step evaluates conditions against a BigQuery view for the contact and routes\naccordingly. All CRM, call and engagement data is mirrored into BigQuery, so a gate can ask\nquestions the application database cannot answer.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `gateBigqueryView` | View name, e.g. `outreach_app.contacts`. Must expose a `hubspotcontactid` column\n| `gateConditions` | `[{ field, operator, value }]` — the current shape\n| `gateMatchMode` | `all` (AND) or `any` (OR)\n| `gateField`, `gateOperator`, `gateValue` | Deprecated single-condition form, still read for old rows\n| `trueBranchStepIndex` | Where to jump when the gate passes\n| `falseBranchStepIndex` | Where to jump when it fails\n| `gateRetryDelayHours` | When set, a failure waits this long and re-checks instead of branching\n| `gateMaxRetries` | Cap on re-checks; `null` is unbounded\n|===\n\nOperators: `is_not_null`, `is_null`, `equals`, `not_equals`, `gt`, `lt`, `is_in`, `is_not_in`.\n`value` is required for all but the null checks, and is a comma-separated list for the `in`\noperators. Values may contain `+{{token}}+` references to contact columns, resolved before querying.\n\n=== Safety\n\nView, field and identifier *names* are SQL identifiers and cannot be bound as query parameters, so\n`lib/bigquery/client.ts` validates them against a strict allowlist before interpolation. The\nidentifier *value* is always passed as a bound parameter.\n\nA contact with no `hubspotContactId` has nothing to scope the query by, so every condition is\ntreated as not met — the gate fails closed rather than passing on missing data.\n\n=== Routing\n\n`computeAdvance(step, totalSteps, gateResult, gateRetryCount)` is pure:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Situation | Decision\n\n| Not a gate, or no gate result | Advance to `stepIndex + 1`; past the last step → `complete`\n| Gate passed | `trueBranchStepIndex` if set, else the next step; out of range → `complete`\n| Gate failed, `gateRetryDelayHours` set, retries remain | `retry` after `gateRetryDelayHours`\n| Gate failed, retries exhausted | `falseBranchStepIndex` if set, else `abandon`\n| Gate failed, no retry delay | `falseBranchStepIndex` if set, else the next step\n|===\n\nA gate with retries but no false branch and no remaining retries *abandons* the sequence. That is\ndeliberate: \"we waited as long as we agreed to and the condition never became true\" is an outcome,\nnot a step to skip past.\n\n== CRM event steps\n\n`extraConfigs` holds `{ event_type, body }`, validated by `CrmEventConfigSchema`.\n\n* `event_type` comes from the registry in `lib/sequences/crm-event.ts` — currently\n  `Crm.TaskCreate` and `Contact.Update`. Adding a type is one entry in `CRM_EVENT_TYPES`.\n* `body` is arbitrary JSON. The reserved key `event_type` is rejected at validation *and* stripped at\n  render time, so the selected type can never be overridden by user input.\n\n=== Template variables\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Group | Tokens\n\n| Contact columns (`CONTACT_TEMPLATE_VARIABLES`) | `first_name`, `last_name`, `email`, `phone`, `mobile_phone`, `company_name`, `job_title`, `disposition`, `lifecycle_stage`, `lead_status`, `hubspot_contact_id`, `hubspot_owner_id`, `hubspot_deal_id`, `hubspot_deal_stage`, `hubspot_deal_status`\n| Run context (`SEQUENCE_CONTEXT_VARIABLES`) | `sequence_created_at`\n| System (`SYSTEM_TEMPLATE_VARIABLES`) | `now` (ISO-8601), `now_int` (epoch ms), `now_date` (UTC `YYYY-MM-DD`)\n|===\n\nThe curated list is what the editor surfaces; at runtime `buildContactVariables` derives the map\nfrom the actual contact row, so *any* column is usable via its snake_case name.\n\nType preservation is deliberate. A string that is *exactly one token* (`+\"{{lead_score}}\"+`) resolves\nto the variable's natural JSON type, so a numeric-looking string id such as `hubspot_contact_id`\nstays a string while an integer column stays a number. Tokens embedded in longer strings are\nstringified; missing tokens render as empty string. System variables are computed at execution time\nand applied last, so no contact column can shadow `+{{now}}+`.\n\n== Enrollment\n\n`POST /api/contact-sequences` with `{ contact_id, template_id }` and optional `campaign_id`,\n`campaign_target_id`, `email_campaign_target_id`.\n\n`SequenceExecutionService.enrollContact` enforces *one active sequence per contact* in application\ncode rather than with a unique constraint — a `(contactId, status)` unique index would wrongly block\na contact from having several completed or paused enrollments.\n\nLifecycle endpoints: `+POST /api/contact-sequences/{id}/start+`, `/stop`, `/restart`;\n`+GET /api/contact-sequences/{id}+` returns the full detail including the step timeline.\n\n== Statuses\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `SequenceStatus` | Meaning\n\n| `pending` | Created, waiting for a trigger\n| `active` | Running\n| `paused` | Manually paused\n| `completed` | Reached the end of the template\n| `abandoned` | Ran out of steps or gate retries with no response\n| `exited` | Pivoted to a different sequence\n|===\n\n== Execution\n\nThe `sequenceExecute` Inngest function (`retries: 1`, `concurrency: { limit: 50 }`) runs a contact's\n*entire* journey in one durable run.\n\n[source,typescript]\n----\ncancelOn: [{ event: \"sequence/cancel\", match: \"data.contactSequenceId\" }]\n----\n\nA `sequence/cancel` event tears the run down immediately, including an in-progress `step.sleep`.\nThat is why the loop does not need a per-iteration ownership re-check: stopping a sequence is\nhandled by the runtime, not by polling.\n\n=== Shape\n\n. *`start-execution`* — one-time init. Loads the enrollment with its template steps and contact,\n  refuses to restart a terminal sequence, claims the run by stamping `executionId = runId` and\n  marking it `active`, and returns the immutable run context.\n. *Loop*, indexing the once-loaded context in memory:\n* No step at this index → `complete-no-step-<i>` and finish.\n* `wait` channel → `step.sleep` for `delayHours` in `delayUnit`, then a lightweight\n  `record-wait` log with no response payload.\n* `data_gate` on a re-check → sleep `gateRetryDelayHours` first, then `check-gate-<tag>`.\n* Otherwise → `execute-action-<tag>`.\n* `finish-<tag>` applies the `computeAdvance` decision.\n. *`abandon-runaway`* — a `MAX_ITERATIONS` ceiling of 1000 as defence in depth. Gate retries are\n  already bounded by `gateMaxRetries`; this guards against a mis-wired branch cycling forever.\n\nStep ids embed the step index, retry count, channel and a slugified name — for example\n`execute-action-3-0-email-intro-follow-up` — so the Inngest dashboard is readable and ids stay\nstable across replays.\n\nEach side effect is its own `step.run`, so it retries independently and is skipped on replay.\nSleeping runs consume no concurrency, so a template with a seven-day wait costs nothing while waiting.\n\n== Step logs\n\nEvery execution writes a `ContactSequenceStep` row sharing the run's `executionId`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `templateStepId` | Nullable with `onDelete: SetNull` — editing a template severs the link but keeps the history\n| `status` | `pending`, `executed`, `skipped`, `failed`\n| `outcome` | `replied`, `opened`, `connected`, `no_answer`, `no_reply`, or a channel-specific string such as `email:<id>`\n| `gateRetryCount` | Re-check count for gate steps\n| `callId`, `emailId`, `followUpTaskId`, `activityId` | Links to whatever the step produced\n| `response` | The full payload from the execution — provider ids, CRM event body, gate evaluation details. One row per execution, never overwritten\n| `executedAt` | When it ran\n|===\n\n`response` is what makes an enrollment debuggable months later: the exact body sent to SQS, the\nexact BigQuery row a gate saw.\n\n== Enrollment list\n\n`GET /api/contact-sequences` supports `page`, `page_size`, `search` and `status`, and returns the\ncontact's name, email and company plus the template's name and type alongside each enrollment.\n"},{"component":"dialer-v2","module":"domain","path":"domain:contacts.adoc","title":"Contacts, sellers and registries","description":"The contact graph — sellers, contacts, phone and email registries, and CRM synchronisation.","url":"https://docx.payability.com/dialer-v2/domain/contacts.html","navOrder":21,"source":"= Contacts, sellers and registries\n:description: The contact graph — sellers, contacts, phone and email registries, and CRM synchronisation.\n\nEverything the platform contacts resolves to a `Contact`. Sellers sit above contacts as the company;\nthe phone and email registries sit below as the contactable endpoints.\n\n[mermaid]\n....\nerDiagram\n  Seller ||--o{ Contact : employs\n  Contact ||--o{ TargetPhoneNumbers : \"phone endpoints\"\n  Contact ||--o{ TargetEmails : \"email endpoints\"\n  Contact ||--o{ CampaignTarget : \"dialer enrollment\"\n  Contact ||--o{ EmailCampaignTarget : \"email enrollment\"\n  Contact ||--o{ ContactSequence : \"nurture enrollment\"\n  Contact ||--o{ Activity : \"interaction log\"\n  Contact ||--o{ FollowUpTask : \"rep tasks\"\n....\n\n== Sellers\n\nA `Seller` is a company selling on a marketplace. Sellers map to HubSpot *Companies*, not Contacts.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field group | Fields\n\n| Identity | `platform` (`amazon`, `walmart`, `shopify`), `datasource` (`smartscout`, `manual`), `sellerId` (globally unique), `name`, `businessName`, `email`, `phone`\n| Lifecycle | `lifeCycleStage` — `new`, `enriched`, `qualified`, `opportunity`, `closed_won`, `closed_lost`\n| Marketplace metrics | `estimateSales`, `avgPrice`, `percentFba`, `numberReviewsLifetime`, `numberReviews30Days`, `numberAsins`, `numberTopAsins`, `numberWinningBrands`, `numBrands`, `singleBrand`, `brandNameMatch`\n| Growth | `moMGrowth`, `threeMonthGrowth`, `sixMonthGrowth`, `yearGrowth`, `moMGrowthCount`, `sixMonthGrowthCount`\n| Categories | `primaryCategory`, `primarySubCategory`\n| Address | `street`, `city`, `state`, `country`, `zipCode`\n| Suspension | `isSuspended`, `lastSuspendedDate`, `startedSellingDate`\n| HubSpot | `hubspotCompanyId` (unique), `hubspotOwnerId`, `hubspotSyncStatus`, `hubspotLastSyncedAt`, `hubspotEngagementId`\n| Enrichment | `website`, `description`, `linkedinUrl`, `industry`, `annualRevenue`, `numberOfEmployees`, `payoutPainScore`, `enrichedAt`, `scrapedAt`\n| Diagnostics | `note`, `errors` (JSON array)\n|===\n\n`payoutPainScore` is the qualification signal: how much a seller is likely to suffer from\nmarketplace payout delays, and therefore how relevant Payability's product is to them.\n\n`GET /api/sellers` filters by `search`, `platform`, `isSuspended`, `hubspotSyncStatus`, with\npagination. The Sellers sidebar item is hidden; use `/suppliers` for Payability suppliers.\n\n`GET /api/suppliers` lists the BigQuery-synced `Supplier` copy. Primary filters are\n`api_status`, `access_status`, and `cancelation_status` (`__none__` for unset), plus `search`,\n`status`, and `payability_status`. Detail is `+GET /api/suppliers/{id}+`.\n\n== Contacts\n\n`Contact` is the single source of truth for a person, shared between the dialer, email campaigns,\nsequences and HubSpot.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field group | Fields\n\n| Identity | `firstName`, `lastName`, `email`, `phone`, `mobilePhone`, `companyName`, `jobTitle`, `website`\n| Address | `street`, `city`, `state`, `country`, `zipCode`\n| Source | `source` (`campaign`, `hubspot`, `import`, `manual`; stored as a string), `sellerId`\n| Lifecycle | `lifecycleStage` (mirrors HubSpot `lifecyclestage`), `leadStatus` (mirrors `hs_lead_status`), `heatStatus`, `leadScore`, `totalRevenue`\n| Calling context | `disposition`, `nextAction`, `stopCalling`, `validConversation`, `lastCallRecordingUrl`\n| HubSpot | `hubspotContactId`, `hubspotOwnerId`, `hubspotSyncStatus`, `hubspotLastSyncedAt`, `hubspotEngagementId`, `hubspotSyncPriority`, `hubspotDealId`, `hubspotDealStatus`, `hubspotDealStage`\n| Supplier | `supplierKey`, `supplierStatus`, `supplierCancellationStatus`, `supplierMarketplace`\n| Free-form | `notes`, `customFields` (JSON)\n|===\n\n=== Heat status\n\n`ContactHeatStatus` encodes engagement recency and is what prioritisation reads:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Value | Meaning\n\n| `hot` | Active follow-up in progress\n| `warm` | A rep acted but the deal has not closed\n| `warming` | A task failed; an automated email track is running\n| `idle` | 30 days of silence — deprioritised\n|===\n\n=== Uniqueness\n\nFour composite unique constraints, all scoped by workspace: `(subscriptionId, email)`,\n`(subscriptionId, phone)`, `(subscriptionId, hubspotContactId)`, `(subscriptionId, supplierKey)`.\nNullable-unique semantics apply — many rows may have `NULL`, but non-null values must be unique. This\nis what makes \"upsert the contact, then create the enrollment\" safe for CSV imports.\n\n=== Endpoints\n\n`GET /api/contacts` filters by `search`, `name`, `email`, `phone`, `hubspotId`, `sellerId`,\n`source`, `lifecycleStage`, `leadStatus`, `stopCalling`, `hasEmail`, `hasSupplierKey`.\nThe detail page is backed by `+GET /api/contacts/{id}+` plus `/stats`, `/calls`, `/activities`,\n`/emails` and `/phones`.\n\n`POST /api/contacts/crm-queue` pushes a contact onto the CRM queue on demand — the manual escape\nhatch when an automatic sync did not fire.\n\n[#registries]\n== Registries\n\nThe registries are the *contactability layer*. A campaign enrolls a target, but whether that target\nmay actually be contacted is decided by its registry row.\n\n=== TargetPhoneNumbers\n\nOne row per canonical phone number, globally unique on `phoneNumber`. Table `target_phone_numbers`.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `phoneNumber` | E.164 number, globally unique\n| `contactId` | Optional link to the owning contact\n| `carrierName`, `countryCode`, `mobileCountryCode`, `mobileNetworkCode` | Carrier and numbering metadata\n| `lineType` | `LineTypes` — `mobile`, `landline`, `fixedVoip`, `nonFixedVoip`, `tollFree`, `sharedCost`, `premium`, `voicemail`, `pager`, `voip`, `uan`, `unknown`, `other`\n| `riskLevel` | `RiskLevel` — `low`, `medium`, `high`, `critical`\n| `isActive`, `isDeleted`, `isBlocked` | Lifecycle and block flags\n| `syncTwilio`, `syncHubspot`, `hubspotSyncedAt` | Sync state\n|===\n\n*Dial eligibility* (`isTargetPhoneRegistryEligibleForDial`): not blocked, not deleted, and\n`riskLevel` is `low` or unset. `medium` and above are refused — a medium-risk number is more likely\nto be a reassigned or flagged line, and dialing it risks both a wasted call and a compliance problem.\n\nA `CampaignTarget` may point at a registry row through `targetPhoneNumbersId`. A target with *no*\nregistry row is still dialable: the registry enriches and restricts, it is not a prerequisite.\n\nWhen a call ends in DNC, or with an uncallable disconnection reason (`UNCALLABLE_DISCONNECTION_REASONS`),\n`callEndedAnalysis` sets `isBlocked = true` and refreshes `updatedAt` on the registry row. That is\nwhat makes the number permanent across every campaign in the workspace. The cron lease\n`get_and_lease_due_targets` does not select targets whose registry row is blocked.\n\n`GET /api/target-phone-numbers` filters by `search`, `is_active`, `is_blocked`, `risk_level`,\n`include_deleted`. The hub is at `/targets/phone-numbers` (`/targets` redirects there).\n\n=== TargetEmails\n\nOne row per address, unique on `(subscriptionId, email)`. Table `target_emails`.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `email` | Canonical address, normalised to lowercase in the application\n| `contactId` | Optional link to the owning contact\n| `isActive`, `isDeleted`, `isBlocked` | Lifecycle; `isBlocked` is set on spam complaints\n| `newsletterOptOut` / `newsletterOptOutAt` | RFC 8058 or hosted unsubscribe, or the Resend `email.unsubscribed` event\n| `marketingOptOut` / `marketingOptOutAt` | Set on `email.complained`\n| `oneToOneOptOut` | Opt-out for direct one-to-one email; not part of the campaign suppression check\n| `newsletterLastSentAt` | Last marketing send, used for the cross-campaign cadence gap\n| `newsletterGapDays` | Minimum days between marketing emails; default `7`\n|===\n\n`EmailCampaignTarget.emailId` is a *required* FK to this table, so an email recipient always has a\nregistry row and suppression can be joined in at lease time.\n\n`GET /api/target-emails` filters by `search`, `is_active`, `is_blocked`, `newsletter_opt_out`,\n`include_deleted`.\n\n== HubSpot synchronisation\n\nThe platform mirrors identity into HubSpot rather than treating HubSpot as the source of truth:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Local model | HubSpot object\n\n| `Seller` | Company (`hubspotCompanyId`)\n| `Contact` | Contact (`hubspotContactId`)\n| `Call` | Engagement (`hubspotEngagementId`)\n| `FollowUpTask` | Task (`hubspotTaskId`)\n|===\n\nSync direction:\n\n* *Outbound* — CRM events are published to SQS (`Crm.DialerCallEnded`, `Crm.NewActivity`,\n  `Crm.CustomEvent`, `Crm.DealUpdate`, `Crm.TaskCreate`, `Contact.Update`,\n  `Contact.Unsubscribe`) and applied by a downstream service. Handlers only publish when a HubSpot\n  contact id is present, so a consumer never receives an unresolvable payload.\n* *Inbound* — `POST /api/webhooks/hubspot` verifies HubSpot's signature and republishes normalised\n  payloads as `Crm.HubSpotWebhook`.\n\n`hubspotSyncStatus` is `synced`, `pending`, `failed` or `skipped`.\n\n== Contact fields that gate outreach\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Effect\n\n| `stopCalling` | Excludes the contact from dialing; set on DNC\n| `lifecycleStage` in `SKIP_CALL_LIFECYCLE_STAGES` (`customer`, `marketingqualifiedlead`) | Pre-validation skips the target with `lifecycle_stage`\n| `lifecycleStage` in `HUBSPOT_WARM_LEAD_LIFECYCLE_STAGES` (`salesqualifiedlead`, `opportunity`, `closedwon`, `closedlost`) | Warm-lead set, reserved for the `warm_lead` skip reason\n| `disposition` | A prior terminal disposition on the number blocks re-dialing\n|===\n\n[NOTE]\n====\n`HUBSPOT_WARM_LEAD_LIFECYCLE_STAGES` and `CallSkipReason.WARM_LEAD` are defined but not currently\nreferenced by the dial pre-validation path. The active lifecycle guard uses\n`SKIP_CALL_LIFECYCLE_STAGES`.\n====\n"},{"component":"dialer-v2","module":"domain","path":"domain:sales-reps.adoc","title":"Sales reps and follow-up tasks","description":"Rep identity across CRM systems, task assignment, escalation and hand-off.","url":"https://docx.payability.com/dialer-v2/domain/sales-reps.html","navOrder":22,"source":"= Sales reps and follow-up tasks\n:description: Rep identity across CRM systems, task assignment, escalation and hand-off.\n\n`SalesRep` is a workspace-scoped identity for a human seller. It is deliberately separate from\n`Profile`: a rep must exist in the system — assignable, syncable to HubSpot and Nooks — before, or\neven without, having a login.\n\n== SalesRep\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `name`, `email` | Identity; unique on `(subscriptionId, email)`\n| `profileId` | Optional link to a `Profile` once the rep has a Supabase account\n| `hubspotOwnerId` | HubSpot owner, used to assign contacts and tasks\n| `nooksUserId` | Nooks user, used to attribute Nooks calls and assign sequences\n| `activeListId` | HubSpot active list backing the rep's sequences\n| `hubspotCallSegmentId` | HubSpot call segment for attributing calls\n| `isActive` | Only active reps are assignable\n|===\n\nThe record is workspace-scoped so the same person can carry different CRM ids in different\nworkspaces.\n\nEndpoints: `GET|POST /api/sales-reps`, `+GET|PATCH|DELETE /api/sales-reps/{id}+`,\n`+GET /api/sales-reps/{id}/calls+`, `+GET /api/sales-reps/{id}/tasks+`, and\n`GET /api/sales-reps/profile-match?email=` which resolves the signed-in user to their rep record.\n\n== Rep resolution\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Context | Resolution order\n\n| Nooks call attribution | `callData.userData.userId` → `SalesRep.nooksUserId` (workspace-scoped, then global); fallback `callData.userData.email` → `SalesRep.email`, case-insensitive\n| Sequence `call_task` assignment | Contact's `hubspotOwnerId` → an active rep with that `hubspotOwnerId`; fallback to the oldest active rep in the workspace\n| Dashboard \"my tasks\" | `GET /api/sales-reps/profile-match?email=<signed-in email>`\n|===\n\n`SalesRep.nooksUserId` must be populated for the primary Nooks match to work; email is only a\nfallback and breaks when a rep uses a different address in Nooks.\n\n== FollowUpTask\n\nA task assigned to a rep about a specific lead, optionally mirrored into HubSpot and/or Nooks.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `salesRepId` | Assignee\n| `contactId` | The lead\n| `campaignTargetId`, `contactSequenceId` | Optional links to what created the task\n| `type` | `FollowUpTaskType` — `call`, `email`, `sequence_enrollment`\n| `status` | `FollowUpTaskStatus` — `pending`, `in_progress`, `completed`, `skipped`, `failed`\n| `hubspotTaskId`, `nooksTaskId`, `nooksSequenceStateId` | External identifiers\n| `hubspotSyncStatus`, `nooksSyncStatus` | `pending`, `synced`, `failed` per platform\n| `escalationStage` | `0 → 1 → 2 → 3 = failed`\n| `inngestRunId` | Stored so an escalation run can be cancelled programmatically\n| `lastEscalatedAt` | Last escalation timestamp\n| `dueAt`, `completedAt` | Scheduling\n| `lastHubspotSyncAt`, `lastNooksSyncAt` | Per-platform sync timestamps\n| `notes` | Free text\n|===\n\nBoth external ids are nullable because a workspace may have only one of the two integrations\nactive. Storing `inngestRunId` on the row is what allows a completed task to cancel its own pending\nescalation instead of letting it fire against work already done.\n\n=== Escalation\n\n`escalationStage` advances when a task is not actioned within its window, and reaching stage `3`\nmarks the task failed. Each advance stamps `lastEscalatedAt`. The escalation ladder is what turns\n`Contact.heatStatus` from `hot` to `warming`: a failed rep task hands the lead back to an automated\nemail track.\n\n=== Endpoints\n\n`GET /api/follow-up-tasks` filters by `search`, `status`, `type`, `sales_rep_id`, `due_filter`,\n`sort`, `created_from`, `created_to`, with pagination. The response includes a `stats` block\nalongside `data`/`total`/`page`/`pageSize`, so the task board can render its counters from one\nrequest.\n\n`POST /api/follow-up-tasks` creates a task; `+GET|PATCH /api/follow-up-tasks/{id}+` reads and updates\none. The board is at `/tasks`, and a rep-scoped view at `+/sales-reps/{id}/tasks+`.\n\n== Nooks-driven outcomes\n\nCalls made by reps in Nooks arrive at `POST /api/webhooks/nooks` and are stored as `Call` rows with\n`channelType = \"nooks_call\"`, `campaignId = null` and `targetId = null` — they are not campaign\nactivity. Two disposition-gated flows follow:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Flow | Trigger\n\n| AI analysis | Any disposition *not* in `NOOKS_ANALYSIS_SKIP_DISPOSITIONS` (`\"No Answer\"`, `\"Voicemail\"`, `\"Busy\"`) fires `nooks/call.logged` → `nooksCallAnalysis`\n| CRM closed-lost | A disposition in `NOOKS_CLOSED_LOST_DISPOSITIONS` (`\"Not Interested\"`, `\"Do Not Call\"`, `\"Marketplace not supported\"`, `\"No longer selling/shut down\"`) publishes `Crm.DealUpdate` with `deal_stage: \"closedlost\"`\n|===\n\nSee xref:events:nooks.adoc[] for payloads and contact-id resolution.\n"},{"component":"dialer-v2","module":"domain","path":"domain:reporting.adoc","title":"Reporting and activity","description":"The reporting views, the metrics they expose, and the unified activity feed.","url":"https://docx.payability.com/dialer-v2/domain/reporting.html","navOrder":23,"source":"= Reporting and activity\n:description: The reporting views, the metrics they expose, and the unified activity feed.\n\nMost reports are served from PostgreSQL views rather than computed in application code, so a report\nis a thin route over a query. The re-engagement daily call summary and results reports read\nBigQuery instead. All date filters are anchored to Eastern Time via `parseDateInNY` or US-Eastern\n`YYYY-MM-DD` strings.\n\n== Reports and their sources\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Report | Endpoint | Source view\n\n| Daily call stats | `GET /api/reports/daily-stats` | `vm_main_campaign_reports` (view over `call_daily_stats`)\n| Dashboard daily calls summary | `GET /api/reports/daily-call-outcomes` | `Call`, grouped by Eastern day\n| Funnel | `GET /api/reports/funnel` | `main_required_summaries`\n| Sankey disposition flow | `GET /api/reports/sankey-chart` | `main_sankey_chart_view`\n| Daily agent performance | `GET /api/reports/daily-agent-performance` | `vm_main_daily_agent_performance_report` (view over `agent_daily_performance_stats`)\n| Phone-number pickup rate | `GET /api/reports/phone-number-pickup-rate` | `phone_number_pickup_rate`\n| Email campaign daily engagement | `GET /api/reports/email-campaign-daily` | `v_email_campaign_daily_engagement`\n| Email campaign target summary | `GET /api/reports/email-campaign-target-summary` | aggregation over `email_campaign_targets`\n| AI calling performance | `GET /api/reports/ai-calling` | BigQuery `hubspot4.v_calls_latest` joined to `hubspot4.v_deals_latest`\n| Re-engagement daily call summary | `GET /api/reports/reengagement-daily` | BigQuery `outreach_app.v_re_engagement_daily_call_summary`\n| Re-engagement contacts | `GET /api/reports/reengagement-contacts` | BigQuery daily list entries joined to `hubspot4.v_calls_latest`\n| Re-engagement results | `GET /api/reports/reengagement-results` | BigQuery `outreach_app.v_re_engagement_results_summary`\n|===\n\nMost views prefixed `vm_` are materialised. If a report looks stale, check the refresh schedule for\nits view before suspecting the route. The two exceptions are `vm_main_campaign_reports` and\n`vm_main_daily_agent_performance_report`, which keep the `vm_` name for compatibility but are now\nplain views over incrementally maintained rollup tables.\n\n[#rollups]\n== Daily report rollups\n\nThe daily call stats and daily agent performance reports are backed by rollup tables rather than\nmaterialized views:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Rollup table | Exposed as | Script\n\n| `call_daily_stats` | `vm_main_campaign_reports` | `lib/supabase/sql/call_daily_stats.sql`\n| `agent_daily_performance_stats` | `vm_main_daily_agent_performance_report` | `lib/supabase/sql/agent_daily_performance_stats.sql`\n|===\n\nA full `REFRESH MATERIALIZED VIEW` re-aggregates every call ever made, so its cost grows with\nhistory. At roughly 10–13k calls a day that became untenable: both refresh jobs began failing on the\n120 s `statement_timeout` and the campaign report silently froze for a month.\n\nInstead, `refresh_call_daily_stats(p_from, p_to)` and\n`refresh_agent_daily_performance_stats(p_from, p_to)` recompute one inclusive America/New_York date\nrange, deleting and reinserting those days in a single transaction so readers never see a partial\nwindow. `pg_cron` calls them every 10 minutes for the trailing 3 days, plus a nightly 30-day\nre-sync. Both windows are fixed, so refresh cost does not grow with history.\n\nA 3-day hot window is safe because a `Call` row stops being updated within about 21 hours of\ncreation. Anything later is caught by the nightly re-sync.\n\nBoth refreshes depend on `idx_call_created_at`. Without an index on `\"createdAt\"` alone the planner\nfalls back to a composite index where `createdAt` is the third column and scans all of it, which\nmakes a windowed aggregate slower than scanning the whole table.\n\n`refreshed_at` on each table records when a day was last recomputed. To rebuild history — after\nchanging an aggregate expression, for example — call the refresh function directly over the range,\nchunked to stay inside the statement timeout.\n\n[#metric-derivation]\n== How the daily metrics are derived\n\nThe definitions below are the contract these two reports actually implement. They are recorded here\nbecause several are not what the column name suggests, and a reader comparing two reports — or\nreconciling a number against `Call` by hand — will otherwise conclude the data is wrong.\n\n=== Shared conventions\n\n*Date bucketing.* `Call.createdAt` is a `timestamp without time zone` holding UTC. Every report\nbuckets by Eastern calendar day, which is why the expression converts twice: first tagging the value\nas UTC, then reading it in `America/New_York`. A call at 02:00 UTC belongs to the previous Eastern\nday. The conversion is DST-aware, so the UTC offset is −4 or −5 depending on the date.\n\n*The two reports count different populations.* Daily call stats includes rows where `channelType` is\neither `phone_call` or `NULL`; daily agent performance includes only `phone_call`. Measured against\nthe full table, that is 993,540 calls versus 993,157 — the 383 rows with a NULL `channelType` appear\nin one report and not the other. The totals are therefore not expected to reconcile, and the gap is\nnot a bug.\n\n=== Daily call stats volume fields\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Derivation\n\n| `unique_calls` | Count of `Call` rows for the day. Despite the name it does *not* deduplicate by\nphone number — see <<naming-caveats>>.\n| `total_duration_m` | `durationMs` summed across the day, converted to minutes, rounded to two\ndecimals. NULL durations are ignored by `SUM` rather than treated as zero.\n| `billed_minutes` | Each call's duration rounded *up* to a whole minute individually, then summed.\nThis is deliberately not `total_duration_m` rounded up: 1,000 five-second calls bill 1,000 minutes,\nnot 2. It mirrors per-call carrier billing.\n| `calls_picked_up` | Calls whose disposition is *not* one of `Voicemail_Detected`, `AI_Screening`,\n`dial_no_answer`, `dial_busy`, `user_declined`, `invalid_destination`, `user_hangup`, `error_retell`.\nBecause SQL `NOT IN` yields NULL for a NULL disposition, calls with no disposition are also excluded.\nSo `calls_picked_up` + the eight excluded counts + `no_disposition` equals `unique_calls`.\n|===\n\n=== Daily call stats disposition fields\n\nEach is a straight count of calls carrying that `Call.disposition` value. Most column names differ\nfrom the stored value, so the mapping matters when reconciling against the table:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Field | `Call.disposition` value\n\n| `qualified_lead` | `Qualified_Lead` *or* `Amazon_Seller` — the only field that folds two values together\n| `call_back` | `Callback_Requested`\n| `not_qualified` | `Not_Qualified`\n| `not_a_seller` | `Not_a_Seller`\n| `not_interested` | `Not_Interested`\n| `wrong_num` | `Wrong_Number`\n| `voice_mail` | `Voicemail_Detected`\n| `ai_screening` | `AI_Screening`\n| `ivr` | `IVR`\n| `busy` | `Busy`\n| `hangup_early` | `Hangup_Early`\n| `ai_issue` | `AI_Issue`\n| `do_not_call` | `Do_Not_Call`\n| `gatekeeper` | `Gatekeeper`\n| `phone_provider_unavailable` | `telephony_provider_unavailable`\n| `tel_provider_perm_denied` | `telephony_provider_permission_denied`\n| `dial_failed`, `dial_no_answer`, `dial_busy`, `user_declined`, `user_hangup`, `invalid_destination`, `error_retell` | Same name as the stored value\n| `no_disposition` | `NULL`\n|===\n\nDispositions are free-text, not a database enum, and the table above is not exhaustive of what is\nactually stored. Around 2,400 calls carry one of roughly 17 other values — `marked_as_spam` (the\nlargest at ~1,500), `No_Response`, `sip_routing_error`, `Hang Up`, `error_asr`, and several\ntitle-case variants such as `Confirmed Amazon Seller` that predate the current naming.\n\nTwo consequences. The disposition columns do not sum to `unique_calls`. And because those values are\nnot in the exclusion list, they all land in `calls_picked_up` — so that figure currently includes\n~1,500 spam-flagged calls and a few hundred other non-answers. Treat it as an upper bound.\n\nAdding a column, or extending the exclusion list, means editing the aggregate in\n`lib/supabase/sql/call_daily_stats.sql` and re-running the refresh function over the affected history\nto restate it.\n\n=== Dashboard daily calls summary\n\nThe home-page Daily Calls Summary chart does not read `calls_picked_up`. It queries `Call` for the\nselected range (`GET /api/reports/daily-call-outcomes`) so the three series can use duration.\n\nThe population matches daily call stats: `channelType` is `phone_call` or `NULL`, bucketed by Eastern\nday. Each bar stacks three exclusive parts that add up to total calls:\n\n* *Conversations* — connects whose disposition is not `Do_Not_Call`, `Wrong_Number`, or `Hangup_Early`.\n* *Other connects* — the rest of the connects. These are Do Not Call, wrong number, and hang up early.\n* *Not connected* — every other call that day.\n\nA connect is a call with `durationSec` greater than 0, `inVoicemail` not true, and a disposition other\nthan `Voicemail_Detected` or `AI_Screening`. The percent above each bar is connects divided by total\ncalls. Days with no calls are still returned so the axis stays continuous.\n\n=== Daily agent performance fields\n\nOne row per Eastern day and `Call.agentId`, with `agent_name` resolved through `ai_agents`. Rows\nwith no agent collapse into a single per-day row rather than being dropped.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Derivation\n\n| `total_calls` | Count of `phone_call` rows for that day and agent\n| `total_analysis` | How many of those calls have a `call_ai_agent_analysis_results` row. Analysis is\n1:1 with `Call`, so this can never exceed `total_calls`\n| `avg_score`, `total_score` | Mean and sum of `Call.score`; calls without a score are ignored, not zero\n| `correct_dispositions` | Count of analyses where `correctDisposition` is true\n| `correct_disposition_pct` | Mean of `correctDisposition` cast to 0/1, times 100. The denominator is\nanalysed calls, not `total_calls`, so a lightly sampled day can show 100% off a handful of analyses\n| `avg_qa_ai_agent_score`, `total_qa_ai_agent_score` | Mean and sum of the analysis `agentScore`\n| `total_critical_issues` | Number of entries in the analysis `issues` JSON with `priority` set to\n`critical`, summed. A call with no analysis contributes `0`, so a day with no analyses at all reports\n`0` rather than NULL\n| `hangup_calls` | Calls dispositioned `Hangup_Early`\n| `hangup_disposition_rate_pct` | `hangup_calls` as a percentage of `total_calls`, two decimals, NULL\nwhen the day has no calls\n|===\n\n[#naming-caveats]\n=== Naming caveats\n\n`unique_calls` counts calls, not unique phone numbers. The rollup's internal naming still carries\ntraces of an earlier intent — deduplicating repeated calls to the same number per day and keeping the\nlatest disposition — but the grouping key is the call's primary key, so no deduplication happens.\nA number dialled three times in one day contributes three to `unique_calls`. Read the column as\n\"calls that day\".\n\nChanging this would move every historical figure in the report, so it is a product decision rather\nthan a cleanup. Until that decision is taken, the name is misleading but the behaviour is stable.\n\n== Daily call stats\n\nOne row per date plus a `totals` object, both validated by `DailyStatSchema` in\n`lib/schema/reports.ts`.\n\nVolume: `unique_calls`, `total_duration_m`, `billed_minutes`, `calls_picked_up`.\n\nPer-disposition counts: `qualified_lead`, `call_back`, `not_qualified`, `not_a_seller`,\n`not_interested`, `wrong_num`, `voice_mail`, `ai_screening`, `busy`, `hangup_early`, `ai_issue`,\n`do_not_call`, `ivr`, `gatekeeper`, `dial_failed`, `phone_provider_unavailable`, `dial_no_answer`,\n`user_declined`, `invalid_destination`, `dial_busy`, `user_hangup`, `error_retell`,\n`tel_provider_perm_denied`, `no_disposition`.\n\nReturning a `totals` object alongside the daily rows means the UI never re-sums the page and\ntherefore cannot disagree with the database.\n\nHow each of these is computed — including the per-call rounding behind `billed_minutes` and what\n`calls_picked_up` excludes — is in <<metric-derivation>>.\n\n== Funnel\n\nFour stages, returned both `daily` and `aggregated`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Stage | Meaning\n\n| `total` | All calls\n| `valid_conversation` | Duration ≥ 10 s, not voicemail, not screened\n| `hubspot_synced` | Passed CRM eligibility and was handed off\n| `follow_up_needed` | Requires a human next step\n|===\n\n== Sankey\n\n`main_sankey_chart_view` produces the node/link graph of how calls flow from total volume through\nconversation validity and CRM status into individual dispositions. The response has a `daily` array\nand an `aggregated` object, each `{ nodes: [{ name }], links: [{ source, target, value }] }` where\n`source` and `target` are node indices.\n\n`SANKEY_NODE_COLOR_MAP` in `lib/constants.ts` assigns a stable colour per node name, grouped by\nmeaning: blue for entry, green for positive outcomes, red shades for negative, indigo and purple for\ncall-status problems, teal for AI-related, grey for technical failures, amber for follow-up. Keeping\nthe map in constants rather than the chart component means the same disposition is the same colour\nin every visualisation.\n\n== Daily agent performance\n\nPer date and per AI agent — the report that makes prompt versions comparable:\n\n`total_calls`, `total_analysis`, `avg_score`, `total_score`, `correct_dispositions`,\n`correct_disposition_pct`, `avg_qa_ai_agent_score`, `total_qa_ai_agent_score`,\n`total_critical_issues`, `hangup_calls`, `hangup_disposition_rate_pct`.\n\nAccepts `date`, or `dateFrom` and `dateTo`.\n\nSee <<metric-derivation>> for how each field is computed, and why this report's call total does not\nmatch the daily call stats report.\n\n== Phone-number pickup rate\n\nPer-number pickup rate, filterable by `state` and `provider`. This is the input to keep, rest or\nrelease decisions — see xref:phone-numbers.adoc[].\n\n== Email reports\n\n* *Daily engagement* — sends, opens, clicks and bounces per day, optionally scoped to one\n  `campaignId`.\n* *Target summary* — recipient status distribution over a date range.\n\n== AI calling performance\n\nThe Calls total comes from `vm_main_campaign_reports` (`GET /api/reports/daily-stats`), the same Outreach call log as the dashboard Daily Calls card. Connect rate is calls longer than 0 seconds that are not voicemail or AI screening, divided by that Calls total. Conversation rate is calls longer than 10 seconds that are also not a hang-up (`Hangup_Early` or `user_hangup`), a gatekeeper, or a wrong number, divided by the same total. HubSpot does not receive every AI call. Outcome stages still come from BigQuery `hubspot4.v_calls_latest` where `ownerid` is `86817215`. The Outcome funnel Conversations bar is distinct people with a mapped conversation disposition, shown as a share of contacts reached. It does not use the call-level Conversation rate rules.\nDeals come from `hubspot4.v_deals_latest`, which keeps one row per Stitch-synced deal. The query\ndoes not read the raw `hubspot4.deals` table. Funded status comes from\n`PayabilitySheets.v_supplier_summary_extended.first_purchase_date`.\n\nThe app applies `dateFrom` / `dateTo` (YYYY-MM-DD) with a 366-day cap. Missing dates default to the\nlast 30 Eastern days. A deal counts only when it was created on or after that contact's first AI\ncall in the range. Lost reasons group those lost deals by `lost_reason`, falling back to\n`closed_lost_reason`, with a blank reason shown as No reason. Deal won is the best deal stage `closedwon` (Active (Won)) or `1045206663`\n(Closed Won). Funded is a first purchase on or after that first AI call. Amazon qualified is the\ncontact's current `property_ai_call_outcome` of `Amazon_Seller`, not the status at call time.\n\nThe warm-leads list (`/reports/ai-calling/warm-leads`) shows, per contact, human calls (HubSpot\n`v_calls_latest` with an owner other than `86817215`) and emails (`hubspot4.v_emails_latest`, any\nowner). Both are counted from the contact's first Warm Lead call in the range up to today; contacts\nlisted only for a won deal use their first AI call instead. Engagements are credited to the first\nassociated contact only. Each row links to the contact in HubSpot.\n\nUI: `/reports/ai-calling`, linked from `/reports`. Hook: `useAiCallingReport`.\n\n== Re-engagement daily call summary\n\nSource is the BigQuery view `outreach_app.v_re_engagement_daily_call_summary` (documented in\n`lib/bigquery/v_re_engagement_daily_call_summary.sql`). The view is global — there is no\n`subscriptionId` column — so the API authenticates the session but does not tenant-filter the\nquery.\n\nThe app applies `dateFrom` / `dateTo` (YYYY-MM-DD, list dates) with a 90-day cap. Missing dates\ndefault to the last 14 Eastern days. The service sums `daily` into `by_segment` (including per-\ndisposition call counts) and a `funnel` (`total_contacts`, `called_contacts`,\n`uncalled_contacts`, `never_called_contacts`, `connected_contacts`, `positive_calls_contacts`).\nThe segment chart stacks those HubSpot disposition counts. Funnel stage 3 is distinct listed\ncontacts with at least one mapped conversation disposition. `positive_calls_contacts` is Warm\nLead, Follow up scheduled, API Connected Confirmed, or Account Created. The header and daily chart count re-engagement entries (one list date each). `contact_funnel` counts each person once: people, dialled, conversations, positive outcomes, and attributed wins (first purchase on or after their first list date), plus not dialled and never called. The contact list at `/reports/reengagement-contacts` shows those people and their list history.\n\nUI: `/reports/reengagement-daily`, linked from `/reports` and the Call summary button on\n`/reengagement`. Hook: `useReengagementDailyReport`.\n\n== Re-engagement results\n\nSource is the BigQuery view `outreach_app.v_re_engagement_results_summary` (documented in\n`lib/bigquery/outreach_app/v_re_engagement_results_summary.sql`). One view row is one HubSpot\ncontact that appeared on a daily list, joined to the latest supplier statuses. The view is\nglobal — there is no `subscriptionId` column — so the API authenticates the session but does not\ntenant-filter the query.\n\nThe app does not pull contact-grain rows. `queryReengagementResultsSummary()` runs a single\n`COUNT(*)` / `COUNTIF` over the view (all-time; no date filter). Status matching is\n`LOWER(TRIM(...))`. API `needed` includes both `needed` and `required`. Payability includes\n`suspended` as a first-class bucket. Each group also returns `other` and `null`. `no_supplier`\nis `supplierkey IS NULL`. First-purchase wins: `with_first_purchase` is\n`first_purchase_date IS NOT NULL`; `attributed_wins` is\n`DATE(first_purchase_date) >= DATE(first_listdate)` (purchase on or after the contact's first\ndaily-list appearance).\n\nUI: `/reports/reengagement-results`, linked from `/reports` and the Results button on\n`/reengagement`. Hook: `useReengagementResultsReport`.\n\n== Activity feed\n\n`Activity` is the unified interaction log. `ActivityType` covers `sms`, `email`, `call`, `task`,\n`meeting`, `note`, `reminder`.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field group | Fields\n\n| Scope | `subscriptionId`, `type`\n| Subject | `contactId`, `contactPhone`, `contactEmail`\n| Context | `campaignId`, `targetId`, `taskId`\n| Content | `subject`, `body`, `notes`\n| State | `status` (`sent`, `failed`, `delivered`, `received`), `direction` (default `outbound`)\n| External | `retellChatId`, `hubspotContactId`, `hubspotEngagementId`, `externalId`\n| Extra | `metadata` (JSON)\n|===\n\n`externalId` is unique per `(subscriptionId, externalId)`, which makes webhook-driven activity\nupserts idempotent.\n\n`GET /api/activity` merges `Activity` rows with `Call` rows into one chronological feed, filterable\nby `type` or `types`, `search`, `campaignId`, `dateFrom`, `dateTo`, with standard pagination.\n`lib/utils/map-activity-feed.ts` normalises both sources into one shape, and\n`lib/utils/resolve-activity-campaign-names.ts` resolves campaign names in bulk rather than per row.\n\nCalls are merged from their own table rather than duplicated into `Activity` because `Call` carries\nfar more structure (transcript, recording, analysis) than an activity row could hold.\n\nHooks: `useActivity(params?)` in `lib/hooks/use-activity.ts`. Repository:\n`lib/repositories/activity-repository.ts`.\n\n== Dashboards\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Route | Contents\n\n| `/` | Overview — recent calls, daily stats summary\n| `/reports` | Report index\n| `/reports/daily-stats`, `/funnel`, `/sankey-chart`, `/daily-agent-performance`, `/phone-number-pickup-rate`, `/email-campaign-daily`, `/reengagement-daily`, `/reengagement-results`, `/ai-calling` | Individual reports\n| `/activity` | Unified activity feed\n| `/agents/analysis`, `/agents/issues` | Call-analysis results and issue aggregation\n|===\n\nChart components live in `components/reports/`. Hooks: `use-daily-stats`, `use-yearly-stats`,\n`use-daily-calls`, `use-recent-calls`, `use-daily-agent-performance-chart`,\n`use-email-campaign-target-summary`, `use-reengagement-daily-report`, `use-ai-calling-report`,\n`use-reengagement-results-report`.\n"},{"component":"dialer-v2","module":"data","path":"data:index.adoc","title":"Data model","description":"Prisma schema conventions, model inventory and migration workflow.","url":"https://docx.payability.com/dialer-v2/data/index.html","navOrder":24,"source":"= Data model\n:description: Prisma schema conventions, model inventory and migration workflow.\n\nThe schema lives in a single file, `{prisma-schema}`, against Supabase PostgreSQL. The generated\nclient is emitted to `prisma/generated` and imported as `@/prisma/generated/client`.\n\n[source,prisma]\n----\ngenerator client {\n  provider = \"prisma-client-js\"\n  output   = \"generated\"\n}\n\ndatasource db {\n  provider  = \"postgresql\"\n  url       = env(\"DATABASE_URL\")   // pooled connection, used at runtime\n  directUrl = env(\"DIRECT_URL\")     // direct connection, used by migrations\n}\n----\n\n== Conventions\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Convention | Detail\n\n| Primary keys | `String @id @default(uuid()) @db.Uuid` on every model\n| Timestamps | `createdAt @default(now())` and `updatedAt @updatedAt` on every model\n| Scheduling columns | `@db.Timestamptz(6)` where timezone correctness matters (`dueAt`, `nextEarliestAttemptAt`, `leaseExpiresAt`, `nextStepAt`, `completedAt`)\n| Table naming | Newer models use `@@map(\"snake_case\")`; older ones keep the Prisma default PascalCase table name\n| Field naming | `camelCase` in Prisma, `snake_case` on the wire, `camelCase` again in client TypeScript\n| Soft deletes | Boolean flags (`isDeleted`, `isReleased`, `isActive`) plus a timestamp, never a hard delete\n| Flexible data | `Json` columns for payloads and metadata (`payloadJson`, `configJson`, `customFields`, `metadata`, `response`, `gateConditions`, `extraConfigs`)\n| Tenancy | `subscriptionId` on every tenant-scoped model; see xref:architecture:multi-tenancy.adoc[]\n|===\n\n[WARNING]\n====\nTable naming is inconsistent. `Campaign`, `CampaignTarget`, `Call`, `WebhookEvent`, `PhoneNumbers`,\n`Profile`, `Subscription`, `SubscriptionUser` and `Activity` use the default PascalCase table names,\nwhile everything added later uses `@@map` to a snake_case name. Raw SQL must quote the PascalCase\nnames: `FROM \"PhoneNumbers\"`, not `FROM phone_numbers`.\n====\n\n== Model inventory\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Model | Table | Page\n\n| `Subscription` | `Subscription` | xref:tenancy-identity.adoc[]\n| `SubscriptionUser` | `SubscriptionUser` | xref:tenancy-identity.adoc[]\n| `Profile` | `Profile` | xref:tenancy-identity.adoc[]\n| `Campaign` | `Campaign` | xref:calling.adoc[]\n| `CampaignTarget` | `CampaignTarget` | xref:calling.adoc[]\n| `Call` | `Call` | xref:calling.adoc[]\n| `CallAiAgentAnalysisResult` | `call_ai_agent_analysis_results` | xref:calling.adoc[]\n| `PhoneNumbers` | `PhoneNumbers` | xref:calling.adoc[]\n| `BlockedPhoneNumbers` | `blocked_phone_numbers` | xref:calling.adoc[]\n| `WebhookEvent` | `WebhookEvent` | xref:calling.adoc[]\n| `AiAgent` | `ai_agents` | xref:ai-agents.adoc[]\n| `AiAgentVersion` | `ai_agent_versions` | xref:ai-agents.adoc[]\n| `AgentTestCase` | `agent_test_cases` | xref:ai-agents.adoc[]\n| `Configs` | `configs` | xref:ai-agents.adoc[]\n| `Seller` | `sellers` | xref:contacts.adoc[]\n| `Contact` | `contacts` | xref:contacts.adoc[]\n| `TargetPhoneNumbers` | `target_phone_numbers` | xref:contacts.adoc[]\n| `TargetEmails` | `target_emails` | xref:contacts.adoc[]\n| `Activity` | `Activity` | xref:contacts.adoc[]\n| `EmailCampaignTarget` | `email_campaign_targets` | xref:email.adoc[]\n| `EmailVariation` | `email_variations` | xref:email.adoc[]\n| `Domain` | `domains` | xref:email.adoc[]\n| `EmailSendingDomain` | `email_sending_domains` | xref:email.adoc[]\n| `Emails` | `emails` | xref:email.adoc[]\n| `SequenceTemplate` | `sequence_templates` | xref:sequences.adoc[]\n| `SequenceTemplateStep` | `sequence_template_steps` | xref:sequences.adoc[]\n| `ContactSequence` | `contact_sequences` | xref:sequences.adoc[]\n| `ContactSequenceStep` | `contact_sequence_steps` | xref:sequences.adoc[]\n| `SalesRep` | `sales_reps` | xref:sequences.adoc[]\n| `FollowUpTask` | `follow_up_tasks` | xref:sequences.adoc[]\n| `Segment` | `segments` | xref:segmentation.adoc[]\n| `SegmentMembership` | `segment_memberships` | xref:segmentation.adoc[]\n| `SegmentStrategy` | `segment_strategies` | xref:segmentation.adoc[]\n| `SegmentRun` | `segment_runs` | xref:segmentation.adoc[]\n| `ReengagementDailyListEntry` | `reengagement_daily_list_entries` | xref:segmentation.adoc[]\n|===\n\nEnums are catalogued on xref:enums.adoc[].\n\n== High-level relationships\n\n[mermaid]\n....\nerDiagram\n  Subscription ||--o{ SubscriptionUser : members\n  Profile ||--o{ SubscriptionUser : memberships\n  Subscription ||--o{ Campaign : owns\n  Subscription ||--o{ Contact : owns\n\n  Campaign ||--o{ CampaignTarget : \"call targets\"\n  Campaign ||--o{ EmailCampaignTarget : \"email recipients\"\n  Campaign ||--o{ EmailVariation : variations\n  CampaignTarget ||--o{ Call : attempts\n  Call ||--o{ CallAiAgentAnalysisResult : analyses\n  Call ||--o{ WebhookEvent : events\n  PhoneNumbers ||--o{ Call : \"dialed from\"\n\n  AiAgent ||--o{ AiAgentVersion : versions\n  AiAgentVersion ||--o{ Call : \"spoke on\"\n  AiAgentVersion ||--o{ CallAiAgentAnalysisResult : \"graded by\"\n\n  Seller ||--o{ Contact : employs\n  Contact ||--o{ TargetPhoneNumbers : \"phone endpoints\"\n  Contact ||--o{ TargetEmails : \"email endpoints\"\n  Contact ||--o{ CampaignTarget : \"dialer enrollment\"\n  Contact ||--o{ EmailCampaignTarget : \"email enrollment\"\n  Contact ||--o{ ContactSequence : enrollments\n  Contact ||--o{ Activity : interactions\n  Contact ||--o{ FollowUpTask : tasks\n\n  Domain ||--o{ EmailSendingDomain : accounts\n  EmailSendingDomain ||--o{ Emails : \"sent/received\"\n  EmailCampaignTarget ||--o{ Emails : messages\n\n  SequenceTemplate ||--o{ SequenceTemplateStep : steps\n  SequenceTemplate ||--o{ ContactSequence : enrollments\n  ContactSequence ||--o{ ContactSequenceStep : \"step log\"\n  SalesRep ||--o{ FollowUpTask : assigned\n\n  Subscription ||--o{ Segment : owns\n  Segment ||--o{ SegmentMembership : members\n  Segment ||--o{ SegmentStrategy : methodologies\n  Segment ||--o{ SegmentRun : runs\n  SegmentStrategy ||--o{ SegmentRun : \"applied by\"\n  SegmentStrategy ||--o{ ReengagementDailyListEntry : \"picked under\"\n  SegmentRun ||--o{ ReengagementDailyListEntry : produced\n  Segment ||--o{ ReengagementDailyListEntry : sources\n  Contact ||--o{ SegmentMembership : \"segment membership\"\n  Contact ||--o{ ReengagementDailyListEntry : \"daily picks\"\n  SalesRep ||--o{ ReengagementDailyListEntry : \"assigned picks\"\n....\n\n== Migrations\n\n[source,bash]\n----\npnpm prisma migrate dev --name descriptive_name   # create + apply locally\npnpm prisma migrate deploy                        # apply in an environment\npnpm prisma generate                              # regenerate the client\npnpm prisma format                                # format the schema\npnpm prisma studio                                # browse data\n----\n\nThe repository also defines composite scripts: `pnpm prisma:migrate` runs\n`format && db push && generate` (schema-push, no migration file — development only), and\n`pnpm prisma:migrate:dev` runs `format && migrate dev && generate`.\n\nMigrations live in `prisma/migrations/`, named `<timestamp>_<slug>`, with `migration_lock.toml`\npinning the provider. Do not edit an applied migration; add a new one.\n\n=== Checklist for adding a field\n\n. Edit `{prisma-schema}`.\n. `pnpm prisma migrate dev --name descriptive_name`.\n. Update the Zod schema in `lib/schema/<feature>.ts`.\n. Export the type from `lib/types.ts` via `z.infer<>`.\n. Update the API serialiser (camelCase → snake_case).\n. Invalidate the affected React Query caches.\n. Update this module and the relevant xref:domain:index.adoc[domain] page.\n\n[#one-off-sql]\n== One-off SQL\n\n`prisma/sql/` holds scripts that were run manually and are *not* part of the migration chain:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Script | Purpose\n\n| `auth_hook_before_user_created_payability.sql` | Supabase auth hook enforcing the Payability email domain at signup\n| `backfill_contacts_from_campaign_targets.sql`, `backfill_contact_ids.sql`, `backfill_contacts_from_target_phone_numbers_batch.sql`, `backfill_contacts_tpn_te_from_campaign_targets.sql` | Populate `Contact` and link it from existing targets\n| `backfill_target_emails.sql`, `backfill_target_emails_from_campaign_targets_batch.sql`, `dedupe_target_emails.sql`, `cleanup_target_emails_and_link_email_campaign_targets.sql` | Build and clean the email registry\n| `backfill_target_phone_numbers_batch.sql` | Build the phone registry\n| `backfill_emails_from_activity_sent.sql` | Reconstruct `Emails` rows from historical activity\n| `segmentation_constraints.sql` | Partial unique index and `treatmentPercent` range check that Prisma cannot express — see xref:segmentation.adoc#constraints[Segmentation constraints]\n|===\n\nReporting views (`vm_main_campaign_reports`, `main_required_summaries`, `main_sankey_chart_view`,\n`vm_main_daily_agent_performance_report`, `phone_number_pickup_rate`,\n`v_email_campaign_daily_engagement`) and the `get_and_lease_due_targets` RPC are managed in Supabase,\nnot in this repository.\n\n`vm_main_campaign_reports` and `vm_main_daily_agent_performance_report` are no longer materialised.\nEach is now a plain view over an incrementally maintained rollup table — `call_daily_stats` and\n`agent_daily_performance_stats` respectively — defined in `lib/supabase/sql/`. See\nxref:domain:reporting.adoc#rollups[Daily report rollups]. The remaining `vm_`-prefixed views are\nstill materialised, so check their refresh schedule before treating stale numbers as a bug.\n\n== Database triggers\n\n`handle_justcall_call_initiated()`, wired to `trg_webhookevent_call_initiated`, watches for JustCall\n`call.initiated` rows in `WebhookEvent`, extracts the JustCall user id, and marks the matching\nprofile unavailable while incrementing its active-call count — availability tracking that predates\nthe application-level dialer.\n"},{"component":"dialer-v2","module":"data","path":"data:enums.adoc","title":"Enums","description":"Every Prisma enum, its values and where they are consumed.","url":"https://docx.payability.com/dialer-v2/data/enums.html","navOrder":25,"source":"= Enums\n:description: Every Prisma enum, its values and where they are consumed.\n\nDatabase enums constrain values at the storage layer; TypeScript `const enum`s in `lib/enums.ts`\ncarry values that are not stored (event names, skip reasons).\n\n== Campaign and calling\n\n`CampaignType`:: `call`, `email`\n\n`CampaignStatus`:: `draft`, `running`, `paused`, `stopped`, `completed`\n\n`TargetState`:: `pending`, `in_progress`, `dialing`, `completed`, `failed`, `canceled`, `skipped`,\n`DNC`, `WN`, `MAX`, `DT_PENDING`, `VOICEMAIL`\n+\nThe uppercase values are legacy. Current handler code produces `pending`, `in_progress`, `dialing`,\n`completed`, `failed`, `skipped` and `DNC` only — see\nxref:domain:target-lifecycle.adoc[Target lifecycle].\n+\nNote the spelling: the enum value is `canceled` (one `l`). Some UI code normalises to `cancelled`.\n\n`CallStatus`:: `created`, `dialing`, `answered`, `ended`\n\n== AI agents\n\n`AiAgentType`:: `analysis`, `caller`, `sms`, `other`\n\n`AiAgentLlmModelType`:: `gpt`, `claude` — legacy fallback used when `AiAgent.modelId` is unset\n\n== Contacts and sellers\n\n`ContactSource`:: `campaign`, `hubspot`, `import`, `manual`\n+\nStored on `Contact.source` as a `String`, not as this enum — a migration widened the column.\n\n`ContactLifecycleStage`:: `subscriber`, `lead`, `marketingqualifiedlead`, `salesqualifiedlead`,\n`opportunity`, `customer`, `evangelist`, `other` — mirrors HubSpot `lifecyclestage`\n\n`ContactLeadStatus`:: `new`, `open`, `in_progress`, `open_deal`, `unqualified`,\n`attempted_to_contact`, `connected`, `bad_timing` — mirrors HubSpot `hs_lead_status`\n\n`ContactHeatStatus`:: `hot` (active follow-up), `warm` (rep acted, deal open),\n`warming` (task failed, automated track running), `idle` (30 days silent)\n\n`ContactHubspotSyncStatus`:: `synced`, `pending`, `failed`, `skipped`\n\n`SellerPlatform`:: `amazon`, `walmart`, `shopify`\n\n`SellerDatasource`:: `smartscout`, `manual`\n\n`SellerLifeCycleStage`:: `new`, `enriched`, `qualified`, `opportunity`, `closed_won`, `closed_lost`\n\n== Registries\n\n`LineTypes`:: `mobile`, `landline`, `fixedVoip`, `nonFixedVoip`, `tollFree`, `sharedCost`,\n`premium`, `voicemail`, `pager`, `voip`, `uan`, `unknown`, `other`\n\n`RiskLevel`:: `low`, `medium`, `high`, `critical`\n+\nOnly `low` (or null) is dialable — see xref:domain:contacts.adoc#registries[Registries].\n\n== Email\n\n`EmailTargetStatus`:: `pending`, `requested`, `sent`, `opened`, `bounced`, `unsubscribed`,\n`blocked`, `suppressed`, `failed`\n+\nThere is deliberately no `clicked` value — a click is a `clickedAt` timestamp, not a status.\n`suppressed` means Resend dropped the send because the address is on its suppression list.\n\n`EmailTargetCategory`:: `interested`, `not_interested`, `question`, `out_of_office`, `unsubscribe`,\n`wrong_person`, `other` — LLM classification of a reply\n\n`EmailVariationStatus`:: `pending_generation`, `active`, `archived`\n\n`EmailDirection`:: outbound / inbound direction on `Emails`\n\n`EmailInboxCategory`:: in-app inbox folder on `Emails`\n\n`DomainWarmupStatus`:: `warming_up`, `active` — a label only; it does not gate sending\n\n== Sequences and tasks\n\n`SequenceType`:: `onboarding`, `re_engagement`, `not_connected_outreach`\n\n`SequenceStatus`:: `pending`, `active`, `paused`, `completed`, `abandoned`, `exited`\n\n`SequenceStepChannel`:: `email`, `call_task`, `sms`, `wait`, `data_gate`, `crm_task`, `crm_event`\n\n`FollowUpTaskType`:: `call`, `email`, `sequence_enrollment`\n\n`FollowUpTaskStatus`:: `pending`, `in_progress`, `completed`, `skipped`, `failed`\n\n== Segmentation\n\nSee xref:segmentation.adoc[Segmentation models].\n\n`SegmentPurpose`:: `reengagement`, `email_campaign`, `reporting`, `other`\n\n`SegmentAssignmentMode`:: `round_robin`, `keep_owner`, `unassigned`\n\n`SegmentRunStatus`:: `running`, `completed`, `failed`\n\n`SegmentPrioritySource`:: `sql_view`, `strategy_rules`\n+\nWhich of the two ways a segment's priority number is produced. `lost_deal` and `supplier_account`\ncompute a weighted score in their BigQuery view and the producer only rounds it; the rest compute\npriority in producer code.\n\n`ReengagementArm`:: `control`, `treatment`\n\n== Other\n\n`ActivityType`:: `sms`, `email`, `call`, `task`, `meeting`, `note`, `reminder`\n\n`SubscriptionUserRole`:: `owner`, `admin`, `member`\n\n== TypeScript enums (`lib/enums.ts`)\n\nNot stored in the database.\n\n`CampaignTargetStateEnum`:: `pending`, `in_progress`, `completed`, `failed`, `cancelled`\n+\nA legacy subset with the *British* spelling `cancelled`, unlike the Prisma `canceled`.\n\n`CampaignEventTypeEnum`:: `CALL_STARTED`, `CALL_ENDED`\n\n`CrmWorkflowEventTypeEnum`:: `Crm.DialerCallEnded`, `Crm.DealUpdate`\n\n`JustCallEventTypeEnum`:: `justcall_call.initiated`, `justcall_call.answered`,\n`justcall_call.incoming`, `justcall_call.completed`, `justcall_call.updated`\n\n`CallSkipReason`:: `prior_disposition`, `warm_lead`, `lifecycle_stage`, `dnc`,\n`campaign_not_running`, `invalid_e164`, `target_not_found`, `no_phone_number_available`,\n`outside_call_window`, `retell_error`\n+\nReturned by the dial pipeline instead of thrown, so an ineligible target does not consume a retry.\n\n== Disposition sets (`lib/constants.ts`)\n\n`RetellDisposition` is a TypeScript enum of every disposition value. The sets built from it decide\nrouting:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Set | Values\n\n| `HUBSPOT_WHITE_LISTED_DISPOSITIONS` | `Qualified_Lead`, `Amazon_Seller`, `Callback_Requested`, `Wrong_Number`, `Do_Not_Call`, `Not_a_Seller`, `AI_Issue`\n| `HUBSPOT_BLOCKED_DISPOSITIONS` | `AI_Screening`, `Voicemail_Detected`, `dial_busy`, `dial_no_answer`, `invalid_destination`, `Gatekeeper`, `IVR`\n| `NEGATIVE_DISPOSITIONS_TO_RECALL` | `Gatekeeper`, `IVR`, `dial_no_answer`, `dial_busy`, `AI_Screening`\n| `SKIP_CALL_DISPOSITIONS` | `Do_Not_Call`, `Not_a_Seller`, `Amazon_Seller`\n| `TECHNICAL_FAILURE_REASONS` | `dial_no_answer`, `dial_busy`, `dial_failed`, `inactivity`, `machine_detected`\n| `DOUBLE_TAP_REASONS` | `voicemail_reached`, `machine_detected`, `dial_no_answer`, `user_declined`\n| `BLACKLISTED_AI_ANALYSIS_REASONS` | `voicemail_reached`, `dial_busy`, `dial_no_answer`, `dial_failed`, `telephony_provider_unavailable`, `user_declined`, `invalid_destination`, `telephony_provider_permission_denied`\n| `UNCALLABLE_DISCONNECTION_REASONS` | `invalid_destination`, `telephony_provider_unavailable`, `error_retell`, `error_no_audio_received`, `marked_as_spam`, `telephony_provider_permission_denied`, `error_unknown`\n| `NOOKS_ANALYSIS_SKIP_DISPOSITIONS` | `\"No Answer\"`, `\"Voicemail\"`, `\"Busy\"`\n| `NOOKS_CLOSED_LOST_DISPOSITIONS` | `\"Not Interested\"`, `\"Do Not Call\"`, `\"Marketplace not supported\"`, `\"No longer selling/shut down\"`\n| `SKIP_CALL_LIFECYCLE_STAGES` | `customer`, `marketingqualifiedlead`\n| `HUBSPOT_WARM_LEAD_LIFECYCLE_STAGES` | `salesqualifiedlead`, `opportunity`, `closedwon`, `closedlost`\n|===\n\n`DOUBLE_TAP_REASONS` and `TECHNICAL_FAILURE_REASONS` overlap but are not the same: the former uses\nRetell's `disconnection_reason` vocabulary, the latter mixes disposition and disconnection values.\n\n`RETRY_CONSTANTS`:\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Constant | Value\n\n| `DOUBLE_TAP_WAIT_MS` | `60000` (1 minute)\n| `DEFAULT_COOLING_OFF_HOURS` | `24`\n| `MIN_VALID_CONVERSATION_SECONDS` | `10`\n| `DEFAULT_MAX_ATTEMPTS` | `4`\n|===\n"},{"component":"dialer-v2","module":"data","path":"data:tenancy-identity.adoc","title":"Tenancy and identity models","description":"Subscription, SubscriptionUser and Profile.","url":"https://docx.payability.com/dialer-v2/data/tenancy-identity.html","navOrder":26,"source":"= Tenancy and identity models\n:description: Subscription, SubscriptionUser and Profile.\n\n== Subscription\n\nTable `Subscription`. The tenant boundary — a workspace.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `id` | `String @id @db.Uuid` |\n| `name` | `String` | Display name; created as `\"<user>'s Organization\"` on first signup\n| `createdAt`, `updatedAt` | `DateTime` |\n| `callCycleBudgetUsd` | `Float?` | Call-spend cap in USD for the Eastern billing cycle, the calendar month (1st 00:00 through the last day 23:59). Null means the workspace is not checked. A twice-daily Inngest cron pauses running call campaigns when cycle spend is over this cap.\n|===\n\nIt is the parent of essentially every domain model: `users`, `campaigns`, `phoneNumbers`, `calls`,\n`campaignTargets`, `aiAgents`, `aiAgentVersions`, `callAiAgentAnalysisResults`, `configs`,\n`agentTestCases`, `activities`, `sellers`, `contacts`, `emailCampaignTargets`, `emailVariations`,\n`emailSendingDomains`, `domains`, `targetPhoneNumbers`, `targetEmails`, `emails`, `salesReps`,\n`followUpTasks`, `contactSequences`, `sequenceTemplates`, `blockedPhoneNumbers`.\n\n== SubscriptionUser\n\nTable `SubscriptionUser`. The bridge between a `Profile` and a `Subscription`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `profileId` | `String @db.Uuid` | `onDelete: Cascade` — deleting a profile removes its memberships\n| `role` | `SubscriptionUserRole` | `owner`, `admin`, `member`; default `member`\n|===\n\n*Unique:* `(subscriptionId, profileId)` — the constraint `resolveSubscription()` queries to verify\nmembership. *Index:* `profileId`, for \"which workspaces does this user belong to\".\n\nRoles are enforced in the `/api/subscriptions/members` routes; see\nxref:security:authorization.adoc[].\n\n== Profile\n\nTable `Profile`. The application record of a Supabase user. Global, not workspace-scoped.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `supabaseUserId` | `String @unique @db.Uuid` | Link to Supabase Auth\n| `name` | `String` |\n| `email` | `String? @unique` |\n| `phoneNumber` | `String? @unique` |\n| `justcallUserId` | `String? @unique` | Legacy JustCall agent link\n| `isAvailable` | `Boolean` | Availability for human calling; default `false`\n| `maxConcurrentCalls` | `Int` | Default `1`\n| `activeCalls` | `Int` | Default `0`\n|===\n\n*Index:* `(isAvailable, activeCalls)` — finding an available agent.\n\n*Relations:* `calls`, `subscriptionUsers`, `salesReps`.\n\n`isAvailable`, `activeCalls` and `justcallUserId` are the remains of the JustCall-era human agent\nmodel, still maintained by the `handle_justcall_call_initiated()` database trigger. Current\nrep identity lives on `SalesRep`, which is workspace-scoped and carries CRM ids — see\nxref:sequences.adoc#salesrep[SalesRep].\n\n== Signup\n\n`createProfileAndSubscriptionForUser({ supabaseUserId, name, email })` in\n`lib/auth/subscription.ts`:\n\n. Upsert the `Profile` on `supabaseUserId`.\n. If the profile has no membership at all, create a `Subscription` named\n  `\"<name>'s Organization\"` and a `SubscriptionUser` with role `owner`.\n\nThe membership check means the function is idempotent — calling it again for an existing user does\nnot create a second workspace.\n"},{"component":"dialer-v2","module":"data","path":"data:calling.adoc","title":"Calling models","description":"Campaign, CampaignTarget, Call, CallAiAgentAnalysisResult, PhoneNumbers, BlockedPhoneNumbers and WebhookEvent.","url":"https://docx.payability.com/dialer-v2/data/calling.html","navOrder":27,"source":"= Calling models\n:description: Campaign, CampaignTarget, Call, CallAiAgentAnalysisResult, PhoneNumbers, BlockedPhoneNumbers and WebhookEvent.\n\n== Campaign\n\nTable `Campaign`. Shared by voice and email campaigns; `type` discriminates.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `name` | `String` |\n| `type` | `CampaignType` | `call` (default) or `email`\n| `status` | `CampaignStatus` | default `draft`\n| `subscriptionId` | `String @db.Uuid` | Owning workspace\n| `preferedPhone` | `String?` | Preferred outbound number (note the spelling)\n| `maxConcurrentCalls` | `Int` | default `10`\n| `windowStart`, `windowEnd` | `DateTime? @db.Time(6)` | Time-of-day call window\n| `allowWeekends` | `Boolean` | default `false`\n| `maxAttempts` | `Int?` | Retry ceiling per target\n| `waitHoursToRecall` | `Int?` | default `24`\n| `preferredAiAgentId` | `String? @db.Uuid` | Preferred *analysis* agent\n| `description`, `goal` | `String?` | Shared; also used as AI context\n| `resendTopicId` | `String?` | Resend topic\n| `schedulerIntervalSeconds` | `Int?` | default `30`\n| `emailsPerHour` | `Int?` | Email only\n| `maxEmailsPerDay` | `Int?` | Email only\n| `emailsPerVariationPerHour` | `Int?` | Email only; mapped to column `emailsPerVariation`\n| `emailWindowStart`, `emailWindowEnd` | `DateTime? @db.Time(6)` | Email only\n| `emailAllowWeekends` | `Boolean` | Email only, default `false`\n| `templateId` | `String? @db.Uuid` | Source template (a campaign-less `EmailVariation`), `onDelete: SetNull`\n|===\n\n*Indexes:* `status`, `subscriptionId`, `type`, `templateId`.\n\n*Relations:* `targets`, `calls`, `emailTargets`, `emailVariations` (relation `CampaignVariations`),\n`template` (relation `CampaignTemplate`), `emails`, `contactSequences`,\n`callAiAgentAnalysisResults`, `preferredAiAgent` (relation\n`CampaignPreferredAnalysisAgent`), `subscription`.\n\nTwo distinct relations point at `EmailVariation`: `emailVariations` is the campaign's own set, while\n`template` is the single campaign-less variation it was seeded from.\n\n== CampaignTarget\n\nTable `CampaignTarget`. One phone number in one call campaign.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `campaignId` | `String @db.Uuid` |\n| `subscriptionId` | `String? @db.Uuid` | Nullable for legacy rows\n| `targetPhoneNumbersId` | `String? @db.Uuid` | Registry link\n| `contactId` | `String? @db.Uuid` | Contact link\n| `phoneNumber` | `String` | E.164\n| `payloadJson` | `Json` | CSV columns and metadata; passed to Retell as dynamic variables. Default `'{}'::jsonb`\n| `state` | `TargetState` | default `pending`\n| `attempts` | `Int` | default `0`; sentinel values `9999` / `+999999` disqualify permanently\n| `lastError` | `String?` |\n| `tz` | `String?` | Derived from the area code; defaults to `America/New_York` at evaluation time\n| `amazonSeller` | `String?` | Raw AI answer, e.g. `\"Undetermined\"`\n| `isAmazonSeller`, `meetsMonthlyThreshold`, `interestedInDailyPayments` | `Boolean?` | Cached call outcomes\n| `stopCalling` | `Boolean?` |\n| `disposition` | `String?` | Last call's disposition\n| `lastCallRecordingUrl` | `String?` |\n| `nextAction` | `String?` |\n| `validConversation` | `Boolean?` | `durationSec >= 10`\n| `isTestTarget` | `Boolean?` | default `false`; excluded from CRM sync\n| `hubspotContactId`, `hubspotEngagementId`, `hubspotSyncStatus` | `String?` |\n| `lastAttemptAt`, `nextEarliestAttemptAt`, `leaseExpiresAt` | `DateTime? @db.Timestamptz(6)` | Scheduling\n|===\n\n*Indexes:* `(campaignId, state)`, `(phoneNumber, state)` as `idx_campaign_targets_phone_state`,\n`subscriptionId`, `state`, `contactId`, `nextEarliestAttemptAt` as\n`idx_campaign_targets_next_attempt`, `leaseExpiresAt` as `idx_campaign_targets_lease`.\n\nThe last two exist for `get_and_lease_due_targets`, which filters on both columns on every\nbroadcast tick.\n\n*Relations:* `campaign`, `subscription`, `contact`, `targetPhoneNumber`, `calls`, `followUpTasks`,\n`contactSequences`.\n\nBusiness flags are denormalised onto the target so the targets table renders without joining the\nlatest call.\n\n== Call\n\nTable `Call`. One call attempt — from a campaign, or from an external dialer.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field group | Fields | Notes\n\n| Correlation | `campaignId?`, `targetId?`, `agentId?`, `agentVersionId?`, `subscriptionId?`, `phoneNumberId?`, `targetPhoneNumbersId?`, `salesRepId?`, `profileId?` | All nullable: an external call may have no campaign, target or workspace match\n| Identifiers | `providerCallId` (`@unique`), `targetPhoneNumber`, `aiAgentId`, `aiAgentName` | `providerCallId` uniqueness is what makes webhook upserts idempotent\n| Status | `status` (`CallStatus`, default `created`), `direction` (default `outbound`), `channelType` | `channelType` is `phone_call`, `web_call`, `nooks_call` or `hubspot_call`\n| Timing | `createdAt`, `updatedAt`, `startTimestamp?`, `endTimestamp?` |\n| Duration and cost | `durationSec?`, `durationMs?`, `callCost?` |\n| Connection | `latencyMs?`, `disconnectionReason?`, `retellDisposition?` |\n| Interpretation | `userSentiment?`, `disposition?`, `callSuccessful?`, `score?` | `score` is an operator-set 1–10 rating; `disposition` is the platform's reading of `retellDisposition`\n| Flags | `inVoicemail`, `callScreeningDetected`, `aiSuspicionDetected` (all default `false`), `detectedLanguage?` |\n| Business extraction | `monthlyRevenue?`, `isAmazonSeller?`, `meetsMonthlyThreshold?`, `interestedInDailyPayments?`, `isDecisionMaker` (default `false`) |\n| Next steps | `nextAction?`, `terminationReason?`, `callbackRequested` (default `false`), `proposedCallbackTime?` | `proposedCallbackTime` is the raw AI string, not a parsed date\n| Contact corrections | `contactName?`, `correctContactInfo?`, `correctNumber?` | What the callee said the right details are\n| Content | `summary?`, `transcript?`, `recordingUrl?`, `notes?` | `notes` is operator-entered\n| CRM | `hubspotEngagementId?`, `hubspotSyncStatus?` | `synced`, `failed`, `skipped`\n|===\n\n*Indexes:* `(campaignId, status)`, `(targetPhoneNumber, disposition)` as\n`idx_calls_phone_disposition`, `(targetPhoneNumber, campaignId, createdAt)` as\n`idx_calls_phone_campaign_created`, `subscriptionId`, `providerCallId`, `agentId`, `targetId`,\n`channelType`, `phoneNumberId`, `targetPhoneNumbersId`, `salesRepId`, `hubspotEngagementId`.\n\n`idx_calls_phone_campaign_created` serves both the \"call history for this number across all\ncampaigns\" view and the double tap's `countCallsTodayForTargetPhone` guard.\n\n*Relations:* `campaign`, `target`, `agent` (`AiAgent`), `agentVersion`, `subscription`,\n`phoneNumber`, `registryTargetPhone` (relation `CallRegistryTargetPhone`), `salesRep`, `profile`,\n`WebhookEvent`, `aiAgentAnalysisResults`, `testCases` (relation `CallTestCases`),\n`contactSequenceSteps`.\n\n[NOTE]\n====\n`Call.agentId` is an `AiAgent` UUID. `Call.aiAgentId` is the *Retell* agent identifier, stored as a\nplain string alongside `aiAgentName` for display. They are different things.\n====\n\n=== Inbound HubSpot import\n\n`calls/hubspot-import` (twice daily ET at 08:00 and 20:00, also `calls/hubspot-import.requested`)\nqueries `hubspot4.v_calls_latest` for engagements with `updated` after Configs\n`hubspot_call_sync_cursor` (ISO datetime on the `HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID` workspace),\nthen upserts them into `Call`.\n\n* `providerCallId` = `hubspot:{engagementId}`, `channelType` = `hubspot_call`, `status` = `ended`.\n* Existing Retell/Nooks rows that already have the HubSpot engagement id are skipped.\n* HubSpot-imported rows are updated when recording, disposition, duration, notes, or timestamps change.\n* Contact, sales-rep, and phone-registry links are resolve-only; the call is still created when they are missing.\n* The cursor advances to the max `updated` in a successful non-empty batch. Seed the Configs key before the first run.\n* Does not write `Activity` rows, publish CRM events, or write back to HubSpot.\n\nRecommended index (human applies; agents do not run migrations):\n\n[source,sql]\n----\nCREATE INDEX CONCURRENTLY IF NOT EXISTS \"Call_hubspotEngagementId_idx\"\nON \"Call\" (\"hubspotEngagementId\");\n----\n\n== CallAiAgentAnalysisResult\n\nTable `call_ai_agent_analysis_results`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `callId` | `String @db.Uuid` | `onDelete: Cascade`\n| `campaignId` | `String? @db.Uuid` | `onDelete: SetNull`; null for Nooks calls\n| `subscriptionId` | `String @db.Uuid` | `onDelete: Cascade`\n| `aiAgentId`, `aiAgentVersionId` | `String @db.Uuid` | Both `onDelete: Cascade`\n| `correctDisposition` | `Boolean` | Did the logged disposition match the conversation\n| `suggestedDisposition` | `String?` | Present when `correctDisposition` is false\n| `agentScore` | `Int` | 1–10\n| `summary` | `String` |\n| `issues` | `Json` | `CallAnalysisIssue[]`, default `[]`\n|===\n\n*Unique:* `(callId, aiAgentId, aiAgentVersionId)` — one verdict per call per agent version, so\nre-running with a new prompt version adds a row while re-running the same version overwrites it.\n\n*Indexes:* `callId`, `campaignId`, `subscriptionId`.\n\n== PhoneNumbers\n\nTable `PhoneNumbers`. The pool of outbound (dial-from) numbers.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `phoneNumber` | `String @unique` | Globally unique\n| `subscriptionId` | `String @db.Uuid` |\n| `isDefault`, `isActive`, `isDeleted`, `isReleased` | `Boolean` | `isActive` defaults `true`; the others `false`\n| `smsEnabled` | `Boolean` | default `false`\n| `provider` | `String` | default `external`; also `retell_twilio`, `retell_telnyx`\n| `state` | `String?` | US state code\n| `country` | `String` | default `US`\n| `maxAllowedCalls`, `maxAllowedCallsPerDay` | `Int?` | `null` = unlimited\n| `costPerSecond`, `monthlyCost` | `Float?` |\n| `allowedOutboundShortCodes` | `Json?` | default `[]`\n| `phoneNumberSid` | `String?` | MessagingApp identifier\n| `retellPhoneNumberId` | `String?` | Retell identifier\n| `dailyCallCount` | `Int` | Reset nightly\n| `totalCallCount` | `Int` | Lifetime\n| `lastUsedAt`, `purchasedAt`, `releasedAt`, `deletedAt` | `DateTime?` | `purchasedAt` defaults to `now()`\n|===\n\n*Indexes:* `phoneNumber`, `(state, isActive, isDeleted)` for the selection query, `subscriptionId`.\n\n== BlockedPhoneNumbers\n\nTable `blocked_phone_numbers`. A workspace deny list.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `phoneNumber` | `String @unique` | Globally unique\n|===\n\nDistinct from `TargetPhoneNumbers.isBlocked`: this table blocks a raw number that may never have been\na target, while the registry flag blocks a known contact endpoint.\n\n== WebhookEvent\n\nTable `WebhookEvent`. The raw audit trail of every inbound webhook, written before dispatch.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `source` | `String` | `Retell`, `Resend`, `Nooks`, `JustCall`\n| `eventId` | `String` | Provider event id, or a generated UUID when the provider sends none\n| `eventType` | `String` |\n| `rawPayload` | `Json` | Complete payload\n| `providerCallId` | `String?` | Correlation key\n| `callId` | `String? @db.Uuid` | Linked `Call`, when matched\n| `receivedAt` | `DateTime` | default `now()`\n| `processedAt` | `DateTime?` |\n|===\n\n*Unique:* `(source, eventId)` — the idempotency key. A duplicate delivery upserts to a no-op.\n\n*Indexes:* `providerCallId`, `callId`, `(source, eventId)`, `receivedAt`.\n\nThe table is not workspace-scoped: the payload is captured before a tenant has been resolved.\n\n`webhookEventPurgeCron` deletes rows whose `receivedAt` is strictly older than seven days, including rows that were never marked processed. Replay from this table is only possible for events received in the last seven days.\n\n[NOTE]\n====\n`POST /api/webhooks/five9/route.ts` writes with `source = \"Nooks\"` and `eventType = \"five_9\"`,\ngenerating a random `eventId` and `providerCallId`. Because the id is random, its rows are not\nidempotent on replay, and they share the `Nooks` source with genuine Nooks events. Filter on\n`eventType` to separate them.\n====\n"},{"component":"dialer-v2","module":"data","path":"data:ai-agents.adoc","title":"AI agent and config models","description":"AiAgent, AiAgentVersion, AgentTestCase and Configs.","url":"https://docx.payability.com/dialer-v2/data/ai-agents.html","navOrder":28,"source":"= AI agent and config models\n:description: AiAgent, AiAgentVersion, AgentTestCase and Configs.\n\n== AiAgent\n\nTable `ai_agents`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `name` | `String` |\n| `type` | `AiAgentType` | `analysis`, `caller`, `sms`, `other`\n| `llmModelType` | `AiAgentLlmModelType` | `gpt` or `claude`; the legacy fallback\n| `subscriptionId` | `String? @db.Uuid` | Nullable, allowing a shared agent across workspaces\n| `modelId` | `String?` | Vercel AI Gateway slug, e.g. `openai/gpt-5`. Takes precedence over `llmModelType`\n| `retellAgentId` | `String? @unique` | Retell `agent_id`; set only for `caller` agents linked to Retell\n| `latestVersionId` | `String? @db.Uuid` | The published version; null only before the first version exists\n|===\n\n*Indexes:* `subscriptionId`, `retellAgentId`.\n\n*Relations:* `subscription`, `versions` (relation `AgentVersions`), `latestVersion` (relation\n`LatestVersion`), `campaignsPreferredForAnalysis` (relation\n`CampaignPreferredAnalysisAgent`), `callAiAgentAnalysisResults`, `calls`.\n\nThe two relations to `AiAgentVersion` are intentional: `versions` is the full history,\n`latestVersion` is a pointer to the one currently published.\n\n== AiAgentVersion\n\nTable `ai_agent_versions`. Immutable once written.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `aiAgentId` | `String @db.Uuid` | `onDelete: Cascade`\n| `subscriptionId` | `String? @db.Uuid` |\n| `version` | `Int` | 1-based\n| `mainPrompt` | `String` | default `\"\"`. The call script for a caller agent; the grading criteria for an analysis agent\n| `configJson` | `Json` | default `{}`. Full Retell `IAgentData` for callers; model overrides and supplementary config for analysis agents\n| `createdAt` | `DateTime` | No `updatedAt` — versions are not modified\n|===\n\n*Unique:* `(aiAgentId, version)`. *Indexes:* `aiAgentId`, `subscriptionId`.\n\n*Relations:* `subscription`, `aiAgent`, `agentAsLatest`, `analysisResults`, `calls`.\n\nBecause `Call.agentVersionId` and `CallAiAgentAnalysisResult.aiAgentVersionId` both reference this\ntable, a call can always be replayed against the exact prompt that was live when it happened.\n\n== AgentTestCase\n\nTable `agent_test_cases`. A saved conversation plus its correct answer, for regression-testing prompt\nchanges.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `name`, `notes` | `String?` |\n| `sourceCallId` | `String? @db.Uuid` | The call it was captured from; null for hand-written cases\n| `transcript` | `String?` | Missing transcript ⇒ the case is skipped, not failed\n| `recordingUrl` | `String?` |\n| `disposition` | `String?` | Ground truth\n| `isAmazonSeller`, `aiSuspicionDetected`, `callScreeningDetected`, `isDecisionMaker`, `inVoicemail` | `Boolean?` | Ground truth\n| `userSentiment`, `nextAction`, `callSuccessful` | `String?` | Ground truth\n|===\n\n*Indexes:* `subscriptionId`, `sourceCallId`. *Relation:* `sourceCall` (relation `CallTestCases`).\n\nA `null` ground-truth column means \"not asserted\": the runner reports `pass: null` and excludes the\nfield from the verdict rather than counting it as a pass. See\nxref:domain:ai-agents.adoc#regression-test-cases[Regression test cases].\n\n== Configs\n\nTable `configs`. Per-workspace key/value settings.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `key` | `String @unique` | Globally unique\n| `subscriptionId` | `String @db.Uuid` |\n| `value` | `String` | Always a string; callers coerce\n|===\n\n*Indexes:* `key`, `subscriptionId`.\n\nKnown keys (`CONFIG_KEYS` in `lib/constants.ts`):\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Key | Effect\n\n| `purchase_new_phone_numbers` | Master switch for automatic number purchasing\n| `daily_phone_number_purchase_limit` | Cap on purchases per day\n| `hubspot_call_sync_cursor` | ISO datetime watermark for `calls/hubspot-import`; required before the first successful run\n|===\n\nAutomatic purchasing requires both to be enabled.\n\n[WARNING]\n====\n`key` is globally unique rather than unique per workspace, so two workspaces cannot hold different\nvalues for the same key. Treat `Configs` as effectively global until the constraint is changed to\n`(subscriptionId, key)`.\n====\n\nAccessed through `lib/repositories/config-repository.ts`. Workspace members can list, create, and\nedit values on the Settings **Config** tab (`/settings`), which calls `GET|POST /api/configs` and\n`PATCH /api/configs/{id}`.\n"},{"component":"dialer-v2","module":"data","path":"data:contacts.adoc","title":"Contact and registry models","description":"Seller, Supplier, Contact, TargetPhoneNumbers, TargetEmails and Activity.","url":"https://docx.payability.com/dialer-v2/data/contacts.html","navOrder":29,"source":"= Contact and registry models\n:description: Seller, Supplier, Contact, TargetPhoneNumbers, TargetEmails and Activity.\n\n== Seller\n\nTable `sellers`. A marketplace seller company; maps to a HubSpot *Company*.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field group | Fields | Notes\n\n| Keys | `id` (`String @id`, not `@db.Uuid`), `subscriptionId`, `sellerId` (`@unique`) | `sellerId` is the marketplace's own identifier\n| Classification | `platform` (`SellerPlatform`), `datasource` (`SellerDatasource`), `lifeCycleStage` (`SellerLifeCycleStage`, default `new`), `primaryCategory`, `primarySubCategory` |\n| Identity | `name?`, `businessName?`, `email?` (`@unique`), `phone?` (`@unique`) | Email and phone are *globally* unique, unlike `Contact`\n| Brand | `singleBrand`, `brandNameMatch` (both default `false`), `numBrands?`, `numberWinningBrands?` |\n| Scale | `estimateSales?`, `avgPrice?`, `percentFba?`, `numberAsins?`, `numberTopAsins?`, `numberReviewsLifetime?`, `numberReviews30Days?` |\n| Growth | `moMGrowth?`, `threeMonthGrowth?`, `sixMonthGrowth?`, `yearGrowth?`, `moMGrowthCount?`, `sixMonthGrowthCount?` |\n| Address | `street?`, `city?`, `state?`, `country?`, `zipCode?` |\n| Status | `isSuspended` (default `false`), `lastSuspendedDate?`, `startedSellingDate?` |\n| HubSpot | `hubspotCompanyId?` (`@unique`), `hubspotOwnerId?`, `hubspotSyncStatus?`, `hubspotLastSyncedAt?`, `hubspotEngagementId?` |\n| Enrichment | `website?`, `description?`, `linkedinUrl?`, `industry?`, `annualRevenue?`, `numberOfEmployees?`, `payoutPainScore?`, `enrichedAt?`, `scrapedAt?` | `payoutPainScore` is the qualification signal\n| Diagnostics | `note?`, `errors` (`Json`, default `[]`) | Enrichment failures\n|===\n\n*Indexes:* `subscriptionId`, `platform`, `sellerId`, `hubspotCompanyId`, `country`, `isSuspended`,\n`primaryCategory`.\n\n== Supplier\n\nTable `suppliers`. A Payability supplier-summary copy from BigQuery\n`PayabilitySheets.v_supplier_summary_extended`. Distinct from `Seller` (marketplace company /\nSmartScout) and not linked to `Contact`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field group | Fields | Notes\n\n| Keys | `id` (`@db.Uuid`), `subscriptionId`, `supplierKey` | Identity is Payability `supplier_key`, not `Seller.sellerId`\n| Names | `supplierName?`, `companyName?` |\n| Source times | `sourceCreatedAt?` (`created`), `sourceUpdatedAt?` (`updated`) | BQ `updated` only reflects one table in the view\n| Sync | `lastSyncedAt` | Our last write. Set on every successful upsert, including stale key re-pulls\n| Status | `status?`, `stageName?`, `payabilityStatus?`, `cancelationStatus?`, `accessStatus?`, `apiStatus?` | Overwritten from BQ on each match\n| Primary contact | `contactFirstName?`, `contactLastName?`, `contactEmail?`, `phoneNumber?` |\n| Address | `countryIso2?`, `stateCode?`, `address?`, `zipPostalCode?`, `timezone?`, `hide` (default `false`) |\n| Flags | `legalSigned?`, `contractSigned?` |\n| Scale | `estimatedMonthlyRevenue?`, `revenue?`, `revenue30?` |\n| Ops | `ownerName?`, `storeName?`, `merchantToken?`, `firstPurchaseDate?` (`@db.Date`, BQ `first_purchase_date`) | Calendar date `YYYY-MM-DD`; null when the source has no first purchase\n| Extra | `extra` (`Json?`) | Remaining BQ columns (tax id, UTMs, purchase/mp/chargeback windows, UW fields)\n|===\n\n*Unique:* `(subscriptionId, supplierKey)`.\n\n*Indexes:* `subscriptionId`, `sourceUpdatedAt`, `lastSyncedAt`, `status`.\n\n=== Inbound Payability import\n\n`suppliers/payability-import` (5x daily ET, also `suppliers/payability-import.requested`) queries\n`PayabilitySheets.v_supplier_summary_extended` for up to 1000 rows after Configs\n`supplier_sync_cursor` (`{ updated, supplier_key }`), then upserts them into the workspace in\n`HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID`.\n\n* Skip when `supplier_key` is missing.\n* Create when the key is new to the workspace.\n* Overwrite mapped columns on match so status changes apply (unlike HubSpot contact fill-nulls).\n* Stamp `lastSyncedAt` on every successful upsert — this is the last-updated time we trust, not BQ `updated`.\n* Advance the cursor to the last row's `(updated, supplier_key)` keyset so same-day rows are not skipped.\n* A missing cursor starts at `1970-01-01` / empty key.\n* After the incremental batch, re-fetch up to 1000 existing suppliers with the oldest `lastSyncedAt` (excluding keys just imported). The view's `updated` column does not move when other constituent tables change, so a key re-pull is how status still lands and `lastSyncedAt` advances.\n\nThe workspace list is `/suppliers` (`GET /api/suppliers`). Operators filter on `apiStatus`,\n`accessStatus`, and `cancelationStatus` first. The Sellers sidebar item is hidden; `/sellers`\nremains reachable by URL.\n\n== Contact\n\nTable `contacts`. The single source of truth for a person.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field group | Fields | Notes\n\n| Keys | `subscriptionId`, `sellerId?` |\n| Identity | `firstName?`, `lastName?`, `email?`, `phone?`, `mobilePhone?`, `companyName?`, `jobTitle?`, `website?` |\n| Address | `street?`, `city?`, `state?`, `country?`, `zipCode?` |\n| Source | `source` (`String`, default `manual`), `lifecycleStage?`, `leadStatus?`, `heatStatus` (default `idle`), `totalRevenue?`, `leadScore?` | `source` is a string, not the `ContactSource` enum\n| Calling | `disposition?`, `nextAction?`, `stopCalling` (default `false`), `validConversation?`, `lastCallRecordingUrl?` |\n| HubSpot | `hubspotContactId?`, `hubspotOwnerId?`, `hubspotSyncStatus?`, `hubspotLastSyncedAt?`, `hubspotEngagementId?`, `hubspotSyncPriority?` (default `0`), `hubspotDealId?`, `hubspotDealStatus?`, `hubspotDealStage?` |\n| Supplier | `supplierKey?`, `supplierStatus?`, `supplierCancellationStatus?`, `supplierMarketplace?` |\n| Free-form | `notes?`, `customFields` (`Json`, default `{}`) |\n|===\n\n*Unique:* `(subscriptionId, email)`, `(subscriptionId, phone)`,\n`(subscriptionId, hubspotContactId)`, `(subscriptionId, supplierKey)`. Nullable-unique semantics —\nmany `NULL`s are allowed, non-null values must be unique.\n\n*Indexes:* `subscriptionId`, `sellerId`, `email`, `phone`, `disposition`, `hubspotContactId`,\n`supplierKey`, `source`, `lifecycleStage`, `leadStatus`, `stopCalling`.\n\n*Relations:* `subscription`, `seller`, `emailCampaignTargets`, `activities`, `targetPhoneNumbers`,\n`targetEmails`, `emails`, `campaignTargets`, `followUpTasks`, `contactSequences`.\n\n=== Inbound HubSpot import\n\n`contacts/hubspot-import` (5x daily ET, also `contacts/hubspot-import.requested`) queries\n`hubspot4.v_contacts_latest` for up to 1000 contacts that are not in `outreach_app.contacts`, then\nwrites them into the workspace in `HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID`.\n\n* Skip when `hubspotContactId` already exists.\n* Else match email, then phone, on a local contact with a null HubSpot id and fill empty fields only.\n* Else create a `Contact` with `source = hubspot`.\n* Create or link `TargetEmails` / `TargetPhoneNumbers` when email or phone is present.\n* Does not enroll campaign targets.\n\n== TargetPhoneNumbers\n\nTable `target_phone_numbers`. Canonical registry of dial-*to* numbers.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `phoneNumber` | `String @unique` | Globally unique across workspaces\n| `subscriptionId` | `String @db.Uuid` |\n| `contactId` | `String? @db.Uuid` |\n| `carrierName`, `countryCode`, `mobileCountryCode`, `mobileNetworkCode` | `String?` | Carrier lookup data\n| `lineType` | `LineTypes?` |\n| `riskLevel` | `RiskLevel?` | Only `low` or null is dialable\n| `isActive`, `isDeleted`, `isBlocked` | `Boolean` |\n| `syncTwilio`, `syncHubspot` | `Boolean` | Sync intent flags\n| `hubspotSyncedAt` | `DateTime?` |\n|===\n\n*Relations:* `subscription`, `contact`, `campaignTargets`, plus `calls` through relation\n`CallRegistryTargetPhone`.\n\nSet `isBlocked` to suppress a number across every campaign; the DNC path does this automatically.\n\n== TargetEmails\n\nTable `target_emails`. Canonical registry of email addresses and their opt-out state.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `email` | `String` | Normalised to lowercase by the application\n| `contactId` | `String? @db.Uuid` |\n| `isActive`, `isDeleted`, `isBlocked` | `Boolean` | `isBlocked` set on spam complaints\n| `newsletterOptOut` / `newsletterOptOutAt` | `Boolean` / `DateTime?` | RFC 8058, hosted unsubscribe, or `email.unsubscribed`\n| `marketingOptOut` / `marketingOptOutAt` | `Boolean` / `DateTime?` | Set on `email.complained`\n| `oneToOneOptOut` | `Boolean` | Direct email only; not part of campaign suppression\n| `newsletterLastSentAt` | `DateTime?` | Last marketing send, for the cadence gap\n| `newsletterGapDays` | `Int?` | Minimum days between marketing emails; default `7`\n|===\n\n*Unique:* `(subscriptionId, email)`.\n\n`EmailCampaignTarget.emailId` is a *required* FK here, which is what allows suppression and cadence\nto be joined into the target-leasing query.\n\n== Activity\n\nTable `Activity`. The unified interaction log.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `type` | `ActivityType` | `sms`, `email`, `call`, `task`, `meeting`, `note`, `reminder`\n| `contactId` | `String? @db.Uuid` | Nullable for backward compatibility with pre-`Contact` rows\n| `contactPhone`, `contactEmail` | `String?` |\n| `campaignId`, `targetId`, `taskId` | `String? @db.Uuid` | `taskId` links a `FollowUpTask`\n| `subject`, `body`, `notes` | `String?` | `body` is truncated to 500 characters by producers\n| `status` | `String` | default `sent`; also `failed`, `delivered`, `received`\n| `direction` | `String` | default `outbound`\n| `retellChatId` | `String?` | SMS chat id\n| `hubspotContactId`, `hubspotEngagementId` | `String?` |\n| `externalId` | `String?` | Provider id, used for idempotent upserts\n| `metadata` | `Json?` |\n|===\n\n*Unique:* `(subscriptionId, externalId)`.\n\n*Indexes:* `subscriptionId`, `type`, `hubspotEngagementId`, `createdAt`, `contactPhone`,\n`contactId`, `taskId`.\n\n*Relations:* `subscription`, `contact`, `task`, `contactSequenceSteps`.\n\nCalls are *not* duplicated into `Activity`; `GET /api/activity` merges the `Call` table in at query\ntime, because `Call` carries far more structure than an activity row could hold.\n"},{"component":"dialer-v2","module":"data","path":"data:email.adoc","title":"Email models","description":"EmailCampaignTarget, EmailVariation, Domain, EmailSendingDomain and Emails.","url":"https://docx.payability.com/dialer-v2/data/email.html","navOrder":30,"source":"= Email models\n:description: EmailCampaignTarget, EmailVariation, Domain, EmailSendingDomain and Emails.\n\n== EmailCampaignTarget\n\nTable `email_campaign_targets`. One contact enrolled in one email campaign.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `campaignId` | `String @db.Uuid` |\n| `contactId` | `String @db.Uuid` | *Required* — imports upsert the contact first\n| `subscriptionId` | `String @db.Uuid` |\n| `emailId` | `String @db.Uuid` | *Required* FK to `target_emails`; carries suppression and cadence\n| `email`, `firstName`, `lastName` | `String` / `String?` | Cached from the contact at import time, so the recipient list renders without a join\n| `status` | `EmailTargetStatus` | default `pending`\n| `attempts` | `Int` | default `0`\n| `lastError` | `String?` |\n| `sentAt` | `DateTime?` | Set at Resend hand-off, refreshed by `email.delivered`\n| `openedAt`, `clickedAt`, `unsubscribedAt` | `DateTime?` | First occurrence only for open and click\n| `resendEmailId` | `String?` | Correlation key for outbound webhooks\n| `variationId` | `String? @db.Uuid` | Which variation was sent\n| `sendingDomainId` | `String? @db.Uuid` | Which account sent it\n| `queuedAt` | `DateTime?` | Lease stamp; lock window is 2 days (`EMAIL_TARGET_LOCK_WINDOW_SECONDS`)\n| `hubspotSynced` | `Boolean` | default `false`; true once outbound CRM activity was queued\n| `category` | `EmailTargetCategory?` | LLM reply classification\n| `categorisedAt` | `DateTime?` |\n| `categoryModel` | `String?` | Audit: which model or label produced `category`\n|===\n\n*Unique:* `(campaignId, contactId)` and `(campaignId, email)` — one enrollment per person per\ncampaign, while the same address may be a recipient of many campaigns.\n\n*Indexes:* `(campaignId, status)`, `(campaignId, status, queuedAt)` for the leasing query,\n`(campaignId, sentAt)`, `emailId`, `subscriptionId`, `contactId`, `email`, `resendEmailId`.\n\n*Relations:* `campaign`, `contact`, `subscription`, `variation`, `targetEmail`, `emails`,\n`contactSequences`.\n\nA click sets `clickedAt` but does *not* set a status — there is no `clicked` value in\n`EmailTargetStatus`. Filter recipients on `has_clicked`, not on status.\n\n== EmailVariation\n\nTable `email_variations`. Subject plus HTML body for a campaign, or a reusable template.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `campaignId` | `String? @db.Uuid` | `NULL` ⇒ a campaign-less reusable *template*\n| `subscriptionId` | `String @db.Uuid` |\n| `name`, `subject` | `String` |\n| `htmlBody` | `String` (Text) |\n| `resendTemplateId` | `String?` | Created programmatically; synced on create and update. Null on templates until they are copied into a campaign\n| `templateType` | `String` | Always `\"custom\"`\n| `templateVariables` | `Json` | `[{ key, type, fallbackValue }]`; keys map to contact fields at send time\n| `status` | `EmailVariationStatus` | `pending_generation`, `active`, `archived`\n| `usageCount` | `Int` | Lifetime sends; a statistic, never a gate\n| `queuedAt` | `DateTime?` | Reserved for future scheduling\n|===\n\nEvery send and selection query filters by `campaignId`, so a template can never be sent directly.\n\n`UNSUBSCRIBE_LINK` is injected automatically at send time and is applied *last*, so no user-defined\nvariable can overwrite it.\n\n== Domain\n\nTable `domains`. A Resend-verified sending domain.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` | Unique with `name`\n| `name` | `String` | e.g. `payability.com` or `sales.payability.com`\n| `resendDomainId` | `String?` | Resend domain id\n| `resendStatus` | `String?` | Mirror of the Resend status at link time\n| `dailyCap` | `Int?` | Shared cap across all its accounts; `null` = unlimited\n| `warmupStatus` | `DomainWarmupStatus` | `warming_up` or `active`; a label only\n| `isActive` | `Boolean` | Only accounts under an active domain may send\n| `totalSentCount` | `Int` | Lifetime, never reset by the cron\n|===\n\n*Unique:* `(subscriptionId, name)`.\n\nThe domain has no daily counter of its own — its usage today is the *sum* of its accounts'\n`dailySentCount` for those whose `dailySentDate` is today (UTC).\n\nEach Resend subdomain is its own verified domain with its own id, so `sales.payability.com` is a\nseparate `Domain` row rather than a child of `payability.com`.\n\n== EmailSendingDomain\n\nTable `email_sending_domains`. A concrete `From` address (\"email account\" in the UI).\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `domainId` | `String? @db.Uuid` | Parent `Domain`; nullable until legacy rows are backfilled\n| `domain` | `String` | Host, must match the parent `Domain.name`\n| `fromName`, `fromEmail` | `String` | e.g. `Alex`, `alex@payability.com`\n| `resendDomainId` | `String?` |\n| `isActive`, `isVerified` | `Boolean` | Only active accounts are selected\n| `dailyCap` | `Int?` | Per-account cap; `null` = unlimited\n| `dailySentCount` | `Int` | Incremented at Resend hand-off; counts only for `dailySentDate`; also reset nightly\n| `dailySentDate` | `DateTime? @db.Date` | UTC day `dailySentCount` belongs to. An earlier day (or `null`) means 0 sent today\n| `totalSentCount` | `Int` | Incremented with `dailySentCount`; never reset\n| `sentCount` | `Int` | Lifetime, incremented on `email.sent`\n| `receivedCount` | `Int` | On `email.received`\n| `openedCount` | `Int` | On the *first* `email.opened` per target\n| `clickedCount` | `Int` | On the *first* `email.clicked` per target\n| `bouncedCount` | `Int` | On `email.bounced`\n| `unsubscribedCount` | `Int` | On `email.unsubscribed`\n|===\n\n`dailySentCount` and `sentCount` count different moments on purpose: the first is bumped when the\nmessage is handed to Resend (so the cap reflects committed sends immediately), the second when\nResend confirms it sent. `email.delivered` increments neither, because `email.sent` already did.\n\n== Emails\n\nTable `emails`. Individual messages, inbound and outbound.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `sendingDomainId` | `String? @db.Uuid` | Which account sent or received\n| `contactId`, `campaignId`, `emailCampaignTargetId` | `String? @db.Uuid` | Correlation into the contact graph\n| `threadId` | `String @db.Uuid` | Shared by every message in a conversation\n| `direction` | `EmailDirection` |\n| `inboxCategory` | `EmailInboxCategory?` | In-app folder\n| `category` | `EmailTargetCategory?` | LLM classification mirrored from the campaign target\n| `externalId` | `String` | Resend `email_id`, inbound or outbound\n| `inReplyToExternalId` | `String?` | Parent id for reply correlation\n| `toEmail`, `fromEmail`, `subject` | `String` |\n| `body` | `String` (Text) | Plain text\n| `bodyHtml` | `String?` (Text) |\n| `opened` | `Boolean` | The recipient's tracking pixel fired\n| `readAt` | `DateTime?` | An *operator* read it in the app\n| `archivedAt` | `DateTime?` |\n| `sentAt` | `DateTime?` |\n|===\n\n*Unique:* `(subscriptionId, externalId)`.\n\n*Indexes:* `(subscriptionId, createdAt DESC)` for the inbox list,\n`(subscriptionId, threadId, createdAt)` for thread rendering, `(subscriptionId, archivedAt)`,\n`toEmail`, `fromEmail`, `contactId`, `emailCampaignTargetId`.\n\n*Relations:* `subscription`, `sendingDomain`, `contact`, `campaign`, `emailCampaignTarget`,\n`contactSequenceSteps`.\n\n`opened` and `readAt` are separate because they describe different actors — the recipient and the\noperator.\n"},{"component":"dialer-v2","module":"data","path":"data:sequences.adoc","title":"Sequence and task models","description":"SequenceTemplate, SequenceTemplateStep, ContactSequence, ContactSequenceStep, SalesRep and FollowUpTask.","url":"https://docx.payability.com/dialer-v2/data/sequences.html","navOrder":31,"source":"= Sequence and task models\n:description: SequenceTemplate, SequenceTemplateStep, ContactSequence, ContactSequenceStep, SalesRep and FollowUpTask.\n\n== SequenceTemplate\n\nTable `sequence_templates`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `name` | `String` |\n| `goal`, `description` | `String?` | Also fed to the AI writer\n| `callCampaignDefault`, `emailCampaignDefault`, `smsCampaignDefault` | `Boolean` | Marks this as the default template for that campaign kind\n| `type` | `SequenceType` | `onboarding`, `re_engagement`, `not_connected_outreach`\n| `isActive` | `Boolean` | default `true`\n|===\n\n*Index:* `subscriptionId`. *Relations:* `steps`, `contactSequences`.\n\n== SequenceTemplateStep\n\nTable `sequence_template_steps`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `templateId` | `String @db.Uuid` | `onDelete: Cascade`\n| `stepIndex` | `Int` | Unique per template\n| `channel` | `SequenceStepChannel` | `email`, `call_task`, `sms`, `wait`, `data_gate`, `crm_task`, `crm_event`\n| `delayHours` | `Int` | The delay amount for `wait` steps\n| `delayUnit` | `String` | `\"hours\"` or `\"minutes\"`; only meaningful for `wait`\n| `timeoutHours` | `Int?` | How long a rep has on a `call_task`\n| `name`, `description`, `instructions`, `expectedOutcome` | `String?` | Human and AI context\n| `emailTone` | `String?` | `warm`, `direct`, `social_proof`, `urgency`, `breakup`\n| `emailSubjectHint` | `String?` | Hint to the AI writer; also used as the subject fallback\n| `smsTemplate` | `String?` |\n| `isAiGenerated` | `Boolean` | default `true`\n| `fixedEmailId` | `String? @db.Uuid` | A fixed `EmailVariation` when not AI-generated\n|===\n\n=== Data gate fields\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `gateBigqueryView` | `String?` | e.g. `outreach_app.contacts`; must expose a `hubspotcontactid` column\n| `gateConditions` | `Json?` | `[{ field, operator, value }]` — the current shape\n| `gateMatchMode` | `String` | `\"all\"` (AND) or `\"any\"` (OR); default `\"all\"`\n| `gateField`, `gateOperator`, `gateValue` | `String?` | Deprecated single-condition form, still read\n| `trueBranchStepIndex`, `falseBranchStepIndex` | `Int?` | Branch targets\n| `gateRetryDelayHours` | `Int?` | When set, a failure waits and re-checks instead of branching\n| `gateMaxRetries` | `Int?` | Cap on re-checks; `null` = unbounded\n|===\n\n=== CRM fields\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `crmTaskType` | `String?` | `call`, `email`, `follow_up`\n| `crmTaskDescription` | `String?` | Task body\n| `crmTaskDueHours` | `Int?` | Due-date offset from execution time\n| `extraConfigs` | `Json?` | For `crm_event`: `{ event_type, body }`, validated by `CrmEventConfigSchema`\n|===\n\n*Unique:* `(templateId, stepIndex)`. *Index:* `templateId`.\n\n== ContactSequence\n\nTable `contact_sequences`. One contact's enrollment.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId`, `contactId`, `templateId` | `String @db.Uuid` |\n| `status` | `SequenceStatus` | default `pending`\n| `currentStepIndex` | `Int` | default `0`\n| `nextStepAt` | `DateTime? @db.Timestamptz(6)` |\n| `executionId` | `String?` | The Inngest run id driving this enrollment; shared by all its step logs\n| `campaignTargetId`, `emailCampaignTargetId`, `campaignId` | `String? @db.Uuid` | What triggered the enrollment\n| `startedAt`, `completedAt`, `abandonedAt` | `DateTime? @db.Timestamptz(6)` |\n|===\n\n*Indexes:* `contactId`, `(status, nextStepAt)` for the scheduler, `executionId`, `subscriptionId`,\n`campaignId`, `campaignTargetId`, `emailCampaignTargetId`.\n\n[NOTE]\n====\n\"One active sequence per contact\" is enforced in application code\n(`SequenceExecutionService.enrollContact`), not by a unique constraint. A\n`(contactId, status)` unique index would wrongly prevent a contact from having several completed or\npaused enrollments.\n====\n\n== ContactSequenceStep\n\nTable `contact_sequence_steps`. One row per executed step.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `sequenceId` | `String @db.Uuid` |\n| `templateStepId` | `String? @db.Uuid` | `onDelete: SetNull` — editing a template severs the link but keeps the log\n| `executionId` | `String?` | Mirrors `ContactSequence.executionId`\n| `status` | `String` | `pending`, `executed`, `skipped`, `failed`\n| `gateRetryCount` | `Int` | Re-check count for gate steps\n| `callId`, `emailId`, `followUpTaskId`, `activityId` | `String? @db.Uuid` | Whatever the step produced\n| `executedAt` | `DateTime? @db.Timestamptz(6)` |\n| `outcome` | `String?` | `replied`, `opened`, `connected`, `no_answer`, `no_reply`, or a channel-specific string such as `email:<id>`\n| `response` | `Json?` | Full execution payload — provider ids, CRM event body, gate evaluation. One row per execution, never overwritten\n|===\n\n*Indexes:* `sequenceId`, `executionId`, `templateStepId`, `callId`, `emailId`, `followUpTaskId`,\n`activityId`.\n\n[#salesrep]\n== SalesRep\n\nTable `sales_reps`. Workspace-scoped human seller identity.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `profileId` | `String? @db.Uuid` | Optional — a rep can exist before having a login\n| `name`, `email` | `String` |\n| `hubspotOwnerId` | `String?` | Assigns tasks and contacts in HubSpot\n| `nooksUserId` | `String?` | Attributes Nooks calls and assigns sequences\n| `activeListId` | `String?` | HubSpot active list backing the rep's sequences\n| `hubspotCallSegmentId` | `String?` | HubSpot call segment for call attribution\n| `isActive` | `Boolean` | default `true`\n|===\n\n*Unique:* `(subscriptionId, email)`. *Indexes:* `subscriptionId`, `profileId`, `hubspotOwnerId`,\n`nooksUserId`.\n\n*Relations:* `subscription`, `profile`, `followUpTasks`, `calls`.\n\n== FollowUpTask\n\nTable `follow_up_tasks`.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `salesRepId` | `String @db.Uuid` | Assignee\n| `contactId` | `String @db.Uuid` | The lead\n| `campaignTargetId`, `contactSequenceId` | `String? @db.Uuid` | What created the task\n| `type` | `FollowUpTaskType` | `call` (default), `email`, `sequence_enrollment`\n| `status` | `FollowUpTaskStatus` | `pending` (default), `in_progress`, `completed`, `skipped`, `failed`\n| `hubspotTaskId`, `nooksTaskId`, `nooksSequenceStateId` | `String?` | Nullable — a workspace may run only one integration\n| `hubspotSyncStatus`, `nooksSyncStatus` | `String?` | `pending`, `synced`, `failed`\n| `escalationStage` | `Int` | `0 → 1 → 2 → 3 = failed`\n| `inngestRunId` | `String?` | Stored so a completed task can cancel its own pending escalation\n| `lastEscalatedAt` | `DateTime?` |\n| `dueAt`, `completedAt` | `DateTime? @db.Timestamptz(6)` |\n| `lastHubspotSyncAt`, `lastNooksSyncAt` | `DateTime? @db.Timestamptz(6)` |\n| `notes` | `String?` |\n|===\n\n*Indexes:* `subscriptionId`, `salesRepId`, `contactId`, `campaignTargetId`, `status`, `dueAt`,\n`hubspotTaskId`, `nooksTaskId`.\n\n*Relations:* `subscription`, `salesRep`, `contact`, `campaignTarget`, `contactSequence`,\n`activities`, `contactSequenceSteps`.\n"},{"component":"dialer-v2","module":"data","path":"data:segmentation.adoc","title":"Segmentation models","description":"Segment, SegmentMembership, SegmentStrategy, SegmentRun and ReengagementDailyListEntry.","url":"https://docx.payability.com/dialer-v2/data/segmentation.html","navOrder":32,"source":"= Segmentation models\n:description: Segment, SegmentMembership, SegmentStrategy, SegmentRun and ReengagementDailyListEntry.\n\nThe segmentation tables turn re-engagement from an unrecorded, hardcoded selection into something\nmeasurable. Before them, a producer wrote `call_list_date` / `call_priority` / `call_assigned_to`\nstraight to HubSpot and the run ended — yesterday's cohort was unrecoverable and nothing joined the\nenrolled list back to what the rep actually did.\n\n[IMPORTANT]\n====\nManual refresh is wired: `POST /api/segments/{id}/refresh` enqueues\n`segment/refresh.requested` (one event per active Outreach or BigQuery strategy). An Inngest\ncron (`segmentRefreshCron`, 06:00 and 18:00 America/New_York) does the same for every\nactive `purpose = reengagement` segment across tenants. Outreach compiles `filterRules` to\nPrisma. BigQuery runs `customQuery` (a single `SELECT`/`WITH`), resolves each row through\nordered `contactMappings` (HubSpot id, then contact id, then email, then phone), and inserts\ncontacts who are not already open members. When `prioritySource = sql_view`, refresh\nalso copies `priorityColumn` (a BigQuery result column or Outreach numeric catalog\nfield) onto `SegmentMembership.priorityScore` for inserts and for stayers this\nstrategy owns. A truncated BigQuery job still updates scores for staged rows. When\nthe qualifying scan is complete, the same run soft-closes open members this strategy\nenrolled (`source = strategy:<id>`) who are missing from the result. A truncated\nBigQuery job (hit the 100k row cap) does not remove. `dailyVolumeCap` limits new\ninserts (highest scores first), not the qualifying set. Runs use\n`producer = outreach-refresh` or `bigquery-refresh`. HubSpot strategies are still\nnot executed. Empty filters, empty SQL, empty mappings, or a missing\n`priorityColumn` on a `sql_view` strategy fail the run with no writes.\n\nThe daily list is a later pick from those open members: `POST /api/segments/{id}/daily-list`\nenqueues `segment/daily-list.requested` for that segment.\n`POST /api/reengagement/daily-list` and the 07:00 America/New_York schedule\nrun `reengagementDailyListCron` (`segment/daily-list-cron.requested` when\nmanual, tenant-scoped). Before pick fan-out, `pre-list` find-or-creates a\nHubSpot MANUAL contact segment for every in-scope active sales rep named\n`Outreach-Re-Engagement-{YYYY-MM-DD}-{First}-{Last}` (US-Eastern date;\nsingle-token names omit `{Last}`). Lookup is by that name. Duplicate computed\nnames or any HubSpot failure fail the step so `list` / `enqueue` never run.\nThe cron then fans out `segment/daily-list.requested` per\nactive Outreach or BigQuery strategy on an\nactive `purpose = reengagement` segment. The picker writes\n`reengagement_daily_list_entries` from memberships this strategy owns\n(`source = strategy:<id>`), highest `priorityScore` first, honoring\n`dailyVolumeCap` (remaining for that Eastern day) and `staleDays`. Inserts are\nidempotent on `(segmentId, hubspotContactId, listDate)`. `arm` is `control`;\n`priorityAssigned` is left null. Owner fields follow `assignmentMode`:\n`keep_owner` copies the contact's HubSpot owner and matching sales rep;\n`round_robin` walks active sales reps (and their owner ids) in name order,\ncontinuing from how many this strategy already listed today; `unassigned`\nleaves both null. The pick copies membership `source` onto the daily-list row\nand, for assigned reps, stores `hubspotListId` then `PUT`s those HubSpot\ncontact ids onto that day's MANUAL segment. Unassigned or unmapped rows stay\noff HubSpot. Operators see picks on\nthe segment Daily list tab and at `/reengagement`. The workspace page\nshows that day's pick counts as sliced bars by segment and by owner, and\ncan filter the table and bars by segment.\n====\n\n== Three design decisions\n\n*Membership is the source of truth, not an executable segment definition.* Segments are registry\nrows; membership is materialised by whichever producer wrote it. Nothing is re-evaluated after the\nfact, so point-in-time correctness comes for free and historical numbers never change retroactively.\nIt also means each producer keeps its own query instead of being normalised into a DSL first.\n\n*Membership and the daily list are different things* — different grains, different lifetimes.\nMembership is slow-moving and concurrent (a contact sits in several segments at once); the daily\nlist is one contact, one day, one reason.\n\n*Rollups are not stored.* There is deliberately no aggregate table. `ReengagementDailyListEntry` is\nthe fact table, and per `(segment, strategy, arm, day)` summaries are expected to come from SQL\nviews over it. A materialised rollup would have to be recomputed as its attribution window matured,\nwhich a view gets for free by always reading the current facts.\n\n== Segment\n\nTable `segments`. The reusable audience registry.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `subscriptionId` | `String @db.Uuid` |\n| `key` | `String` | Stable machine name, e.g. `email_click`. Unique per subscription\n| `name`, `description` | `String` / `String?` |\n| `purpose` | `SegmentPurpose` | `reengagement`, `email_campaign`, `reporting`, `other`\n| `precedence` | `Int` | Reserved for a future cross-segment tiebreak; nothing enforces it yet\n| `isActive` | `Boolean` | Disabling a segment is a flag flip, not a deploy\n|===\n\n`purpose` is what keeps the table general — the same registry can hold the email campaign audiences\nthat currently exist only informally.\n\n*Unique:* `(subscriptionId, key)`. *Relations:* `memberships`, `strategies`, `runs`,\n`dailyListEntries`.\n\n== SegmentMembership\n\nTable `segment_memberships`. Slow-moving and concurrent, not a daily snapshot.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `segmentId` | `String @db.Uuid` |\n| `hubspotContactId` | `String` | Required — producers hold the HubSpot id, not the local one\n| `contactId` | `String? @db.Uuid` | Nullable FK to `Contact`, backfilled opportunistically\n| `source` | `String` | default `\"producer\"`; refresh writes `strategy:<id>`\n| `priorityScore` | `Int` | default `0`. Written on `sql_view` refresh from `priorityColumn`\n| `addedAt` | `DateTime @db.Timestamptz(6)` |\n| `removedAt` | `DateTime? @db.Timestamptz(6)` | Soft close\n|===\n\nSoft-closing with `removedAt` preserves \"was this contact in this segment in June\" without writing a\nrow per contact per day. Partial unique indexes enforce at most one open row per `(segmentId, hubspotContactId)`\nand per `(segmentId, contactId)` when the local contact is set — see <<constraints>>.\n\nRefresh removals only close open rows with `source = strategy:<that strategy's id>`. Legacy\n`producer` rows and members enrolled by another strategy are left alone. Stayers keep the same\nrow and `addedAt`; `sql_view` refreshes update `priorityScore` in place. Qualifying\nidentities for the run are staged in `segment_refresh_qualifying`\n(`@@unique([runId, hubspotContactId])`, with `priorityScore`) and deleted on finalize\nor run failure. Email import and the members list order open members by\n`priorityScore DESC`. Score updates stay scoped to `source = strategy:<id>` — a later\nsql_view strategy does not overwrite another strategy's members.\n\n== SegmentStrategy\n\nTable `segment_strategies`. The methodology layer.\n\nSeparating *who* (segment) from *how we treat them* (strategy) is what gives the control/treatment\narms something meaningful to vary. Without the split, \"arm\" has nothing to test.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `segmentId` | `String @db.Uuid` |\n| `name`, `version` | `String`, `Int` | Unique together per segment\n| `prioritySource` | `SegmentPrioritySource` | `sql_view` or `strategy_rules` — see below\n| `priorityColumn` | `String?` | Result / catalog field copied onto membership when `prioritySource = sql_view`. Drafts may be empty; refresh requires it\n| `priorityRules` | `Json?` | Only meaningful when `prioritySource = strategy_rules`\n| `assignmentMode` | `SegmentAssignmentMode` | Applied on daily-list pick: `round_robin` (active sales reps), `keep_owner` (contact owner), `unassigned` (leave blank) |\n| `staleDays`, `dailyVolumeCap` | `Int?` |\n| `treatmentPercent` | `Int` | Share routed to the treatment arm, `0`–`100`\n| `dataSource` | `SegmentDataSource` | `outreach`, `bigquery`, `hubspot`. Default `outreach`\n| `sourceTable` | `SegmentSourceTable` | `contacts`, `calls`, `campaign_targets`, `email_campaign_targets`, `custom_query`. Default `contacts`\n| `filterRules` | `Json?` | react-querybuilder rule group. Outreach only\n| `customQuery` | `String?` | BigQuery SQL or HubSpot filter text. Empty drafts are allowed\n| `contactMappings` | `Json?` | Ordered BigQuery column → identity field fallbacks. Empty drafts are allowed\n| `isActive`, `effectiveFrom` | `Boolean`, `DateTime` |\n|===\n\n`dataSource` and `sourceTable` are paired in Zod, not the database: Outreach uses one of the four app tables plus `filterRules`; BigQuery and HubSpot require `sourceTable = custom_query` and store `customQuery`. BigQuery also stores `contactMappings` (`source_column` + `contact_field`). Manual Outreach refresh compiles `filterRules` in `lib/segments/compile-filter-rules.ts`. BigQuery refresh runs SQL via `querySegmentRows` and resolves rows in `lib/segments/resolve-contact-mappings.ts`. Empty filters, empty SQL, or empty mappings fail the run so they never enroll the whole tenant. A HubSpot-id mapping can enroll with `contactId = null`; email, phone, and contact id only enroll when that Contact already has a HubSpot id.\n\nThe filterable Outreach columns live in `lib/segments/source-table-fields.ts`. The query builder cannot invent a column the catalog does not list.\n\n`prioritySource` exists because the five producers do not agree. For `sql_view`, the named\n`priorityColumn` is the score: BigQuery uses the result column (leaf-name fallback, same as\ncontact mappings); Outreach uses a `type: \"number\"` catalog field, including contact\n`leadScore` / `hubspotSyncPriority` even when the filter is on a child table. Refresh\ncoerces with `Math.round` into `Int`. `strategy_rules` scoring is not implemented —\ninserts stay at `0` and scores are not updated.\n\n`lost_deal` and `supplier_account`\ncompute a weighted `priority_score` in their BigQuery view and the producer only rounds it:\n\n[source,sql]\n----\n-- lib/bigquery/v_lost_deal_reeng.sql\nROUND(\n  LEAST(4.0,\n    CASE\n      WHEN lc.last_call_date IS NULL THEN 4.0\n      ELSE CASE lc.disposition_id ... END\n        * LEAST(1.3, 1.0 + (TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), lc.last_call_date, DAY) / 30.0))\n        * (1.0 / (1.0 + 0.25 * lc.total_calls))\n    END\n  )\n, 1) AS priority_score\n----\n\nThe rest compute priority in producer code. A single `priorityRules` column would have misdescribed\ntwo of the five.\n\n`treatmentPercent` lives here rather than on `Segment` so it versions alongside the methodology it\nvaries.\n\n== SegmentRun\n\nTable `segment_runs`. Provenance for one producer run.\n\nBecause no executable definition is stored, the criteria in effect are frozen in\n`criteriaSnapshot` at write time — query name, thresholds, params. Descriptive, never re-evaluated.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `segmentId`, `strategyId` | `String @db.Uuid`, `String? @db.Uuid` |\n| `runDate` | `DateTime @db.Date` |\n| `producer` | `String` | Handler that wrote the rows\n| `criteriaSnapshot` | `Json` | default `{}`\n| `rowCount`, `status`, `error` | `Int`, `SegmentRunStatus`, `String?` |\n| `startedAt`, `completedAt` | `DateTime @db.Timestamptz(6)` |\n|===\n\n== ReengagementDailyListEntry\n\nTable `reengagement_daily_list_entries`. The daily list plus arm tracking.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `segmentId` | `String @db.Uuid` | Which segment sourced the pick\n| `strategyId` | `String @db.Uuid` | *Required* — every summary groups by strategy, and a NULL would drop the row out of those aggregates\n| `runId` | `String? @db.Uuid` | The producer run that wrote the pick\n| `arm` | `ReengagementArm` | `control` or `treatment`\n| `hubspotContactId` | `String` | Required\n| `contactId`, `hubspotOwnerId`, `salesRepId` | nullable |\n| `hubspotListId` | `String?` | HubSpot ILS list/segment id for the assigned rep's daily list |\n| `source` | `String` | Copied from `SegmentMembership.source` at pick (usually `strategy:<id>`) |\n| `listDate` | `DateTime @db.Date` | US-Eastern calendar day\n| `priorityAssigned` | `Int?` | Rounded 1–4, as written to HubSpot\n| `priorityScore` | `Float?` | Unrounded score when the source view computes one\n| `reason` | `String` | Which rule fired\n| `qualifyingContext` | `Json` | Property values frozen at selection time\n|===\n\n*Unique:* `(segmentId, hubspotContactId, listDate)`.\n\nThat is deliberately *not* `(contactId, listDate)`. Logging every qualifying pick keeps producer\nwiring behaviour-neutral, and makes cross-segment overlap measurable before any precedence rule is\nlegislated:\n\n[source,sql]\n----\nSELECT \"hubspotContactId\", \"listDate\", count(DISTINCT \"segmentId\") AS segments_claiming\nFROM reengagement_daily_list_entries\nGROUP BY 1, 2\nHAVING count(DISTINCT \"segmentId\") > 1;\n----\n\nA winner view or a `Segment.precedence` tiebreak can be layered on later with no data migration.\n\nThe native picker (not the Python producers) sets `reason = open_member` and\n`qualifyingContext = { source, priority_score }` — no PII. `hubspotOwnerId` and\n`salesRepId` are copied from the local contact when present, for display only.\n\n`reason` records which rule fired; `qualifyingContext` records what the contact looked like when it\nfired (lifecycle stage, deal stage, days since last call, click timestamp). Contact properties\ndrift, so without the frozen context later analysis cannot ask questions nobody thought to log for.\n\n[NOTE]\n====\n`listDate` is a US-Eastern calendar day, matching the `call_list_date` the producers write to\nHubSpot. The BigQuery views guard against re-picking with `PARSE_DATE(...) != CURRENT_DATE()`, which\nis UTC and reads a Stitch mirror that lags. That guard stays advisory until this table is the\nauthority.\n====\n\n[#constraints]\n== Constraints outside the schema\n\n`prisma/sql/segmentation_constraints.sql` holds things Prisma cannot express. The open-membership\nuniques also ship in `prisma/migrations/20260902100000_segment_membership_active_unique`.\n\n[source,sql]\n----\nCREATE UNIQUE INDEX IF NOT EXISTS \"segment_memberships_active_unique\"\n  ON \"segment_memberships\" (\"segmentId\", \"hubspotContactId\")\n  WHERE \"removedAt\" IS NULL;\n\nCREATE UNIQUE INDEX IF NOT EXISTS \"segment_memberships_active_contact_unique\"\n  ON \"segment_memberships\" (\"segmentId\", \"contactId\")\n  WHERE \"removedAt\" IS NULL AND \"contactId\" IS NOT NULL;\n\nALTER TABLE \"segment_strategies\"\n  ADD CONSTRAINT \"segment_strategies_treatment_percent_range\"\n  CHECK (\"treatmentPercent\" BETWEEN 0 AND 100);\n----\n\nThe partial unique indexes are what make the soft-close design safe. A plain unique constraint would\nreject a contact who legitimately re-enters a segment later; without any constraint, a contact can\nsilently hold several open membership rows (same HubSpot id, or same local `contactId` with a\ndifferent HubSpot id). Closed rows may repeat.\n\n[WARNING]\n====\nIdentifiers are quoted camelCase. These models use `@@map` for the table name but leave column names\nas Prisma generates them, so raw SQL needs `\"hubspotContactId\"`, not `hubspot_contact_id`.\n====\n\n== Seeding\n\n`scripts/seed-segments.ts` inserts the five existing re-engagement channels as `Segment` rows, each\nwith one `baseline` strategy at `treatmentPercent = 0` — 100% control, so logging can ship before any\nchallenger strategy exists and nothing about who gets called changes.\n\n[source,bash]\n----\nnpx tsx --env-file=.env scripts/seed-segments.ts            # dry-run\nnpx tsx --env-file=.env scripts/seed-segments.ts --confirm  # apply\n----\n\nIt is a script rather than a migration because it needs a real `subscriptionId`. Re-running is safe.\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Key | Producer | `prioritySource`\n\n| `email_click` | `Hubspot.EmailClickReEngagement` | `strategy_rules`\n| `supplier_account` | `Hubspot.SupplierAccountReEngagement` | `sql_view`\n| `unattended_meeting` | `Hubspot.UnattendedMeetingReEngagement` | `strategy_rules`\n| `lost_deal` | `Hubspot.LostDealReEngagement` | `sql_view`\n| `campaign` | `Phone.CampaignTargetReEngagement` | `strategy_rules`\n|===\n\n`staleDays` is left null on the seeded strategies. The current thresholds live as constants in the\nproducer repository, and guessing them here would put wrong numbers into the baseline that later\nstrategy versions get compared against.\n"},{"component":"dialer-v2","module":"api","path":"api:index.adoc","title":"API reference","description":"Conventions, authentication, pagination and error handling for the Dialer V2 HTTP API.","url":"https://docx.payability.com/dialer-v2/api/index.html","navOrder":33,"source":"= API reference\n:description: Conventions, authentication, pagination and error handling for the Dialer V2 HTTP API.\n\nEvery endpoint lives under `app/api/` as a Next.js route handler. There is no other HTTP surface.\n\n== Authentication\n\nThree schemes, depending on the caller.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Caller | Scheme | Endpoints\n\n| Dashboard (browser)\n| Supabase session cookie, resolved by `resolveSubscription()` or `getSupabaseServerClient()`\n| Everything except webhooks, cron, broadcast and MCP\n\n| Internal machine (`pg_cron`, edge functions, other services)\n| `Authorization: Bearer <INTERNAL_API_SECRET>`, verified by `verifyInternalSecret()`\n| `+POST /api/campaigns/{id}/broadcast+`, `+POST /api/email-campaigns/{id}/broadcast+`, cron routes\n\n| External AI orchestrator\n| `Authorization: Bearer <MCP_API_SECRET>` plus `x-subscription-id`\n| `POST /api/mcp`\n\n| Provider webhook\n| Provider signature (Svix, HubSpot HMAC, optional Nooks secret)\n| `POST /api/webhooks/*`\n|===\n\n`verifyInternalSecret()` fails closed: an unset `INTERNAL_API_SECRET` rejects every request rather\nthan allowing them through.\n\n== Tenant scoping\n\nMost endpoints are scoped to the active workspace, resolved in this order:\n\n. `x-subscription-id` request header — an explicit override\n. `active_subscription_id` cookie, set only by `POST /api/subscriptions/active`\n\nMembership is verified against `SubscriptionUser` on every request. See\nxref:architecture:multi-tenancy.adoc[].\n\n== Casing\n\nRequests and responses use `snake_case`. The database uses `camelCase`, and client TypeScript uses\n`camelCase` again via Zod inference — the `snake_case` shape exists only on the wire.\n\nA handful of older list responses still leak `camelCase` keys (for example `isAmazonSeller` and\n`meetsMonthlyThreshold` on campaign targets). Where a page documents a response, its keys are as the\nroute actually emits them.\n\n== Pagination\n\nStandard envelope:\n\n[source,json]\n----\n{ \"data\": [], \"total\": 0, \"page\": 1, \"pageSize\": 25 }\n----\n\nQuery parameters are `page` (1-based) and `pageSize`, with per-endpoint maxima (commonly 100, 200 or\n500). Some newer endpoints use `page_size` instead of `pageSize` — sequences, contact sequences and\nemails do. Each page notes which.\n\nResponses that carry aggregate context add sibling keys next to `data`, for example `stats` on\nfollow-up tasks and `totals` on daily stats, so a UI never has to re-sum the page.\n\n== Date filters\n\n`dateFrom` / `dateTo` (and their snake_case variants) take `YYYY-MM-DD` and are anchored to\n*America/New_York* — midnight and 23:59:59 respectively — by `parseDateInNY` in `lib/utils.ts`. This\nis DST-aware and uses no external library. `dateFrom=2026-01-15` becomes `2026-01-15T05:00:00Z`\nduring EST.\n\nTimestamps in responses are UTC ISO 8601. Call list and detail views render them in Eastern Time with\nthe zone abbreviation so the display matches the filter.\n\n== Errors\n\n[source,json]\n----\n{ \"error\": \"Human readable message\" }\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Status | Meaning\n\n| `400` | Zod validation failed, or a required parameter is missing\n| `401` | No session, or a bad bearer secret\n| `403` | Authenticated but not a member of the workspace, or insufficient role\n| `404` | Resource not found, or not visible in this workspace\n| `500` | Unexpected error — the detail is logged, not returned\n|===\n\nInternal error messages are deliberately generic (`\"Unable to complete operation\"`); the cause goes\nto the logs and to Sentry.\n\n== Endpoint index\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Area | Page\n\n| Register, sign in/out, current user, workspaces and members | xref:auth-workspace.adoc[]\n| Campaigns, targets, coverage, broadcast | xref:campaigns.adoc[]\n| Calls, history, analysis, batch re-analysis | xref:calls.adoc[]\n| AI agents, versions, analysis results, issues, test cases, Retell voices | xref:ai-agents.adoc[]\n| Contacts, sellers, phone and email registries, target list | xref:contacts.adoc[]\n| Email campaigns, recipients, variations, duplication | xref:email-campaigns.adoc[]\n| Templates, variation library, domains, sending accounts, unsubscribe | xref:email-infrastructure.adoc[]\n| Email inbox and threads | xref:emails.adoc[]\n| Sequence templates and enrollments | xref:sequences.adoc[]\n| Sales reps, follow-up tasks | xref:people.adoc[]\n| Outbound phone numbers | xref:phone-numbers.adoc[]\n| Reports, activity feed | xref:reports.adoc[]\n| Provider webhooks, cron jobs, Inngest, internal endpoints | xref:webhooks.adoc[]\n| Model Context Protocol tool server | xref:mcp.adoc[]\n|===\n"},{"component":"dialer-v2","module":"api","path":"api:auth-workspace.adoc","title":"Auth and workspace endpoints","description":"Registration, session, current user, workspace selection and member management.","url":"https://docx.payability.com/dialer-v2/api/auth-workspace.html","navOrder":34,"source":"= Auth and workspace endpoints\n:description: Registration, session, current user, workspace selection and member management.\n\n== Authentication\n\nSignup, sign-in and sign-out go through the API rather than the Supabase browser client, so session\ncookies are set by the server.\n\n=== `POST /api/auth/register`\n\nCreates the Supabase user, then a `Profile`, a `Subscription` and a `SubscriptionUser` with role\n`owner`.\n\nBody:: `{ \"email\": \"...\", \"password\": \"...\", \"name\": \"...\" }`\nResponse:: `{ \"success\": true }` or `{ \"user_id\": \"...\" }`\n\nThe client must then call `POST /api/auth/signin` with the same credentials to establish the session.\nPassword strength is checked by `lib/auth/password-strength.ts`, and the email domain by\n`isAllowedPayabilityEmail()`.\n\n=== `POST /api/auth/signin`\n\nBody:: `{ \"email\": \"...\", \"password\": \"...\" }`\n\nSets the session cookies on the response through the route-handler Supabase client. Call with\n`credentials: \"same-origin\"`.\n\n=== `POST /api/auth/signout`\n\nClears the session and the `active_subscription_id` cookie.\n\nResponse:: `{ \"success\": true }`\n\nThe client should redirect to `/login` and call `router.refresh()` afterwards.\n\n== Current user\n\n=== `GET /api/me`\n\nThe dashboard will not render until this resolves — *WorkspaceGate* blocks on it.\n\nResponse:: `{ \"user\", \"profile\", \"subscriptions\", \"active_subscription_id\" }`\n\n`profile` is the caller's profile within the active workspace; `subscriptions` is every workspace they\nbelong to, with roles.\n\n== Workspace selection\n\n=== `GET /api/subscriptions`\n\nResponse::\n[source,json]\n----\n{\n  \"subscriptions\": [\n    { \"id\": \"uuid\", \"name\": \"Acme\", \"role\": \"owner\", \"created_at\": \"2026-01-01T00:00:00.000Z\" }\n  ],\n  \"active_subscription_id\": \"uuid\"\n}\n----\n\n=== `POST /api/subscriptions/active`\n\nBody:: `{ \"subscriptionId\": \"uuid\" }`\n\nVerifies membership, then sets `active_subscription_id` on the response. This is the *only* place the\ncookie is written — never set it client-side.\n\n== Members\n\nAll member endpoints operate on the *current* workspace, resolved from the cookie or header.\n\n=== `GET /api/subscriptions/members`\n\nResponse:: `{ \"data\": [{ \"id\", \"profile_id\", \"name\", \"role\", \"joined_at\" }], \"total\": n }`\n\nMember data comes from `Profile` only; there is no Supabase Admin API call, so the endpoint stays fast\nand needs no service-role key.\n\n=== `POST /api/subscriptions/members`\n\nOwner or admin only.\n\nBody:: `{ \"email\": \"user@example.com\", \"role\": \"member\" | \"admin\" | \"owner\" }`\n\nLooks up the `Profile` by email and creates a `SubscriptionUser`. Only owners may add an owner.\n\nResponse:: `201 { \"success\": true }`\n\nThe invitee must already have a `Profile` — that is, they must have signed up. There is no\ninvitation-by-email flow.\n\n=== `+PATCH /api/subscriptions/members/{membershipId}+`\n\nOwner or admin only. Body:: `{ \"role\": \"owner\" | \"admin\" | \"member\" }`. Only owners may assign the\n`owner` role.\n\n=== `+DELETE /api/subscriptions/members/{membershipId}+`\n\nOwners may remove anyone; admins may remove non-owners only.\n\nResponse:: `{ \"success\": true }`\n\n== Legacy agent endpoints\n\n`GET /api/agents` and `PATCH /api/agents` operate on `Profile` as a *calling agent* — the\nJustCall-era human availability model.\n\n`GET /api/agents` accepts `isAvailable` and `selectRandom`, and derives an `availability` value:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Value | Condition\n\n| `available` | `isAvailable` and `activeCalls < maxConcurrentCalls`\n| `busy` | `isAvailable` and at capacity\n| `offline` | `isAvailable` is false\n|===\n\n`PATCH /api/agents` updates the caller's own record (`name`, `max_concurrent_calls`, `availability`).\n\nCurrent rep identity lives on `SalesRep` — see xref:people.adoc[].\n\n== Workspace config\n\nKey/value rows in the `configs` table, scoped to the active workspace. Values are always strings;\ncallers coerce them (for example feature flags via `ConfigRepository.isValueEnabled`).\n\n`key` is globally unique rather than unique per workspace — creating a key that already exists\nreturns `409`.\n\n=== `GET /api/configs`\n\nResponse:: `{ \"data\": [{ \"id\", \"key\", \"value\", \"created_at\", \"updated_at\" }], \"total\": n }`\n\n=== `POST /api/configs`\n\nBody:: `{ \"key\": \"purchase_new_phone_numbers\", \"value\": \"true\" }`\n\nResponse:: `201` with the created row.\n\n=== `+PATCH /api/configs/{id}+`\n\nBody:: `{ \"value\": \"false\" }`. The key cannot be renamed. Returns `404` if the row is not in the\nactive workspace.\n"},{"component":"dialer-v2","module":"api","path":"api:campaigns.adoc","title":"Campaigns and targets","description":"Call campaign CRUD, target management, coverage and the broadcast trigger.","url":"https://docx.payability.com/dialer-v2/api/campaigns.html","navOrder":35,"source":"= Campaigns and targets\n:description: Call campaign CRUD, target management, coverage and the broadcast trigger.\n\n`GET /api/campaigns` lists *call* campaigns only (`type = \"call\"`). Email campaigns are at\nxref:email-campaigns.adoc[].\n\n== Campaigns\n\n=== `GET /api/campaigns`\n\nReturns call campaigns with aggregated target counts.\n\n[source,json]\n----\n[\n  {\n    \"id\": \"uuid\",\n    \"name\": \"Campaign Name\",\n    \"type\": \"call\",\n    \"prefered_phone\": \"+1234567890\",\n    \"status\": \"running\",\n    \"max_concurrent_calls\": 10,\n    \"window_start\": \"09:00:00\",\n    \"window_end\": \"17:00:00\",\n    \"allow_weekends\": false,\n    \"max_attempts\": 4,\n    \"wait_hours_to_recall\": 24,\n    \"pending_count\": 100,\n    \"in_progress_count\": 5,\n    \"completed_count\": 50,\n    \"failed_count\": 10,\n    \"total_count\": 165,\n    \"created_at\": \"2026-01-01T00:00:00.000Z\",\n    \"updated_at\": \"2026-01-01T00:00:00.000Z\"\n  }\n]\n----\n\nNote `prefered_phone` — the misspelling matches the Prisma field.\n\n=== `POST /api/campaigns`\n\nBody:: `name`, `max_concurrent_calls`, `prefered_phone`, `window_start`, `window_end`,\n`allow_weekends`, `max_attempts`, `wait_hours_to_recall`\n\nResponse:: `201` with a single campaign object.\n\n=== `+GET /api/campaigns/{id}+`\n\nSingle campaign with target counts, including `type`. The dashboard at `+/campaigns/{id}+` is\nvoice-only, so a `type` of `email` causes a client-side redirect to `+/email-campaigns/{id}+`.\n\n=== `+PATCH /api/campaigns/{id}+`\n\nAll fields optional; only supplied fields are updated. `status` accepts `draft`, `running`, `paused`,\n`stopped`, `completed`.\n\nSet `max_attempts` explicitly — its fallback differs between code paths. See\nxref:domain:call-campaigns.adoc[].\n\n== Targets\n\n=== `+GET /api/campaigns/{id}/targets+`\n\nPaginated targets. `page` (default 1), `pageSize` (default 50, max 200).\n\nFilters:: `search` (phone, partial), `states` (repeatable), `isAmazonSeller`, `validConversation`,\n`meetsMonthlyThreshold`, `stopCalling`, `disposition`, `nextAction`, `hubspotSyncStatus`,\n`riskLevel`, `maxAttempts`\n\nWhen the target is linked to a `TargetPhoneNumbers` row, registry fields are included:\n`target_carrier_name`, `target_line_type`, `target_risk_level`, `target_is_blocked`,\n`target_phone_registry_updated_at`.\n\n[source,json]\n----\n{\n  \"data\": [\n    {\n      \"id\": \"uuid\",\n      \"campaign_id\": \"uuid\",\n      \"phone_number\": \"+1234567890\",\n      \"target_carrier_name\": \"Example Carrier\",\n      \"target_line_type\": \"mobile\",\n      \"target_risk_level\": \"low\",\n      \"target_is_blocked\": false,\n      \"target_phone_registry_updated_at\": \"2026-01-01T00:00:00.000Z\",\n      \"state\": \"pending\",\n      \"attempts\": 0,\n      \"last_error\": null,\n      \"isAmazonSeller\": null,\n      \"meetsMonthlyThreshold\": null,\n      \"interested_in_daily_payments\": null,\n      \"stop_calling\": false,\n      \"disposition\": null,\n      \"last_call_recording_url\": null,\n      \"next_action\": null,\n      \"hubspot_contact_id\": null,\n      \"hubspot_engagement_id\": null,\n      \"hubspot_sync_status\": null,\n      \"valid_conversation\": null,\n      \"last_attempt_at\": null,\n      \"next_earliest_attempt_at\": null,\n      \"lease_expires_at\": null,\n      \"payload\": {},\n      \"tz\": \"America/New_York\",\n      \"created_at\": \"2026-01-01T00:00:00.000Z\",\n      \"updated_at\": \"2026-01-01T00:00:00.000Z\"\n    }\n  ],\n  \"total\": 100,\n  \"page\": 1,\n  \"pageSize\": 50\n}\n----\n\n`isAmazonSeller` and `meetsMonthlyThreshold` are emitted in camelCase here, unlike the rest of the\npayload.\n\n=== `+POST /api/campaigns/{id}/targets+`\n\nSingle target:\n\n[source,json]\n----\n{ \"phone_number\": \"+1234567890\", \"state\": \"pending\", \"payload\": { \"first_name\": \"John\" } }\n----\n\nBulk:\n\n[source,json]\n----\n{ \"targets\": [ { \"phone_number\": \"+1234567890\", \"payload\": {} } ] }\n----\n\nResponse:: `201` with the created targets.\n\nBehaviour:\n\n* Phone numbers are trimmed and validated.\n* `tz` is auto-detected from the area code.\n* A HubSpot contact id may be supplied in `payload` under any of `Record ID`, `record_id`,\n  `RecordId` or `hubspot_id` — the variants accommodate different HubSpot CSV exports.\n* `payload` is stored as `payloadJson` and is passed to Retell as dynamic variables, so its keys are\n  addressable from the agent prompt.\n\n=== `+PATCH /api/campaigns/{id}/targets/{targetId}+`\n\nUpdate a single target, e.g. `{ \"state\": \"completed\", \"disposition\": \"Interested\" }`.\n\n=== `+DELETE /api/campaigns/{id}/targets/{targetId}+`\n\nRemove a target from the campaign.\n\n=== `+PATCH /api/campaigns/{id}/targets/bulk-update+`\n\nApply the same change to many targets in one request — used for bulk state resets and cancellations.\n\n== Campaign calls\n\n=== `+GET /api/campaigns/{id}/calls+`\n\nSame filters as `GET /api/calls` minus `campaignId` (fixed by the URL), plus `targetId`. See\nxref:calls.adoc[].\n\n[#coverage]\n== Coverage\n\n=== `+GET /api/campaigns/{id}/coverage+`\n\nReports how well the workspace's outbound numbers cover the campaign's target geography — which\nstates hold targets but have no local source number. Backed by\n`lib/services/campaign-coverage-service.ts`; types in `lib/types/campaign-coverage.ts`.\n\n=== `+POST /api/campaigns/{id}/coverage/recommendations+`\n\nAsks an LLM (`lib/ai/services/coverage-recommendation-service.ts`) which area codes to acquire next,\ngiven the coverage gaps.\n\n== Broadcast\n\n=== `+POST /api/campaigns/{id}/broadcast+`\n\n*Internal only* — requires `Authorization: Bearer <INTERNAL_API_SECRET>`. Called by `pg_cron`.\n\nBody (optional):: `{ \"campaignId\": \"uuid\" }` — falls back to the URL parameter.\n\nThe route passes a lease of 86 400 seconds, then delegates to\n`PhoneNumberCallingService.processBroadcast`. Response is the `BroadcastResult`:\n\n[source,json]\n----\n{\n  \"callsInitiated\": 5,\n  \"callsSkipped\": 1,\n  \"hasAvailableTargets\": true,\n  \"message\": \"Campaign A - Throttled: 20/20 in-flight runs. Will retry on next broadcast tick.\"\n}\n----\n\nErrors: `401` without the secret; `400` with no campaign id; `500` on an unexpected failure. A\ncampaign that is not `running` raises `CallInitiationError` and surfaces as `500`.\n\nSee xref:domain:call-campaigns.adoc[] for the full loop.\n"},{"component":"dialer-v2","module":"api","path":"api:calls.adoc","title":"Call endpoints","description":"Listing, filtering, inspecting and re-analysing calls.","url":"https://docx.payability.com/dialer-v2/api/calls.html","navOrder":36,"source":"= Call endpoints\n:description: Listing, filtering, inspecting and re-analysing calls.\n\n== `GET /api/calls`\n\nPaginated calls, scoped to the active workspace. `page` (default 1), `pageSize` (default 50, max 200).\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Parameter | Type | Filter\n\n| `search` | string | `targetPhoneNumber`, partial\n| `campaignId` | uuid | Exact campaign\n| `agentId` | uuid | AI agent\n| `callerPhone` | string | Source number, partial\n| `contactName` | string | Partial\n| `disposition` | string | Exact, e.g. `Qualified_Lead`\n| `statuses` | csv | Call statuses, e.g. `ended,dialing`\n| `channelType` | string | `phone_call`, `web_call`, `nooks_call`\n| `hubspotSyncStatus` | string | `synced`, `failed`, `skipped`\n| `inVoicemail` | `yes`/`no`/`all` | Voicemail detection\n| `callScreeningDetected` | boolean |\n| `aiSuspicionDetected` | boolean |\n| `interestedInDailyPayments` | boolean |\n| `meetsMonthlyThreshold` | boolean |\n| `isDecisionMaker` | boolean |\n| `callbackRequested` | boolean |\n| `hasRecording` | `true` | `recordingUrl IS NOT NULL`\n| `dateFrom`, `dateTo` | `YYYY-MM-DD` | `createdAt`, anchored to America/New_York\n|===\n\n[source,json]\n----\n{\n  \"data\": [\n    {\n      \"id\": \"uuid\",\n      \"campaign_id\": \"uuid\",\n      \"campaign_name\": \"Campaign Name\",\n      \"target_id\": \"uuid\",\n      \"agent_id\": \"uuid\",\n      \"ai_agent_id\": \"agent_xxx\",\n      \"ai_agent_name\": \"Single Prompt Brian\",\n      \"agent_version\": 3,\n      \"target_phone_number\": \"+1234567890\",\n      \"caller_phone\": \"+1987654321\",\n      \"contact_name\": \"John Doe\",\n      \"provider_call_id\": \"retell_call_id\",\n      \"status\": \"ended\",\n      \"duration_ms\": 120000,\n      \"duration_sec\": 120,\n      \"disposition\": \"Interested\",\n      \"recording_url\": \"https://…\",\n      \"transcript\": \"…\",\n      \"in_voicemail\": false,\n      \"call_screening_detected\": false,\n      \"ai_suspicion_detected\": false,\n      \"is_decision_maker\": true,\n      \"callback_requested\": false,\n      \"meets_monthly_threshold\": true,\n      \"interested_in_daily_payments\": false,\n      \"hubspot_sync_status\": \"synced\",\n      \"analysis_saved\": true,\n      \"score\": null,\n      \"start_timestamp\": \"2026-01-15T12:30:00.000Z\",\n      \"end_timestamp\": \"2026-01-15T12:32:00.000Z\",\n      \"created_at\": \"2026-01-15T12:30:00.000Z\",\n      \"updated_at\": \"2026-01-15T12:32:05.000Z\"\n    }\n  ],\n  \"total\": 1000,\n  \"page\": 1,\n  \"pageSize\": 50\n}\n----\n\nFields worth knowing:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `ai_agent_id`, `ai_agent_name` | The *Retell* caller agent identifier and display name\n| `agent_id` | The `AiAgent` UUID — a different value from `ai_agent_id`\n| `agent_version` | Version number of the `AiAgentVersion` that spoke\n| `analysis_saved` | `true` when at least one `CallAiAgentAnalysisResult` exists\n| `caller_phone` | The outbound source number, masked in the UI where configured\n| `score` | Operator-set 1–10 rating, not the AI score\n|===\n\nTimestamps are UTC; the UI renders them in Eastern Time.\n\n== `+GET /api/calls/{id}+`\n\nA single call with every field plus campaign and target context.\n\n== `+PATCH /api/calls/{id}+`\n\nUpdates operator-editable fields — `notes` and `score`.\n\n== `+GET /api/calls/{id}/history+`\n\nOther calls *to the same target phone number across all campaigns*, excluding the current call. When\n`targetPhoneNumber` is missing it falls back to the same target within the same campaign.\n\n[source,json]\n----\n{\n  \"data\": [\n    {\n      \"id\": \"uuid\",\n      \"campaign_id\": \"uuid\",\n      \"campaign_name\": \"Campaign Name\",\n      \"target_id\": \"uuid\",\n      \"target_phone_number\": \"+1234567890\",\n      \"status\": \"ended\",\n      \"disposition\": \"Interested\",\n      \"duration_seconds\": 120,\n      \"score\": null,\n      \"in_voicemail\": false,\n      \"start_timestamp\": \"2026-01-01T00:00:00.000Z\",\n      \"created_at\": \"2026-01-01T00:00:00.000Z\"\n    }\n  ],\n  \"total\": 2,\n  \"page\": 1,\n  \"pageSize\": 50\n}\n----\n\nGrouping by phone number rather than by campaign is deliberate: an operator needs to know every time\nthat number has been called, whoever called it.\n\n== `+POST /api/calls/{id}/analyze+`\n\nRe-runs AI analysis for one call via `CallAnalysisService.runAndSave`. See\nxref:domain:call-analysis.adoc[].\n\n== Batch re-analysis\n\n=== `GET /api/calls/batch-analyze`\n\nPreview count for the re-analyze dialog.\n\nParameters:: `campaign_id`, `status`, `date_from`, `date_to`, `hubspot_sync_status` — all optional;\n`all` disables a filter.\n\nResponse:: `{ \"count\": 42 }`\n\n=== `POST /api/calls/batch-analyze`\n\nStarts `batchCallAnalysisWorkflow` fire-and-forget and returns immediately.\n\n[source,json]\n----\n{\n  \"campaign_id\": \"uuid-or-null\",\n  \"status\": \"ended\",\n  \"date_from\": \"2026-01-01\",\n  \"date_to\": \"2026-01-31\",\n  \"hubspot_sync_status\": \"synced\"\n}\n----\n\n[source,json]\n----\n{\n  \"message\": \"Batch call analysis workflow started\",\n  \"filters\": { \"campaign_id\": \"uuid\", \"status\": \"ended\", \"date_from\": \"2026-01-01\", \"date_to\": \"2026-01-31\", \"hubspot_sync_status\": \"synced\" }\n}\n----\n\nThe preview and the run share `buildBatchAnalysisWhere`, so the count cannot disagree with the set\nthat is processed.\n"},{"component":"dialer-v2","module":"api","path":"api:ai-agents.adoc","title":"AI agent endpoints","description":"Agent CRUD, versions, analysis results, issues, prompt suggestions, test cases and Retell voices.","url":"https://docx.payability.com/dialer-v2/api/ai-agents.html","navOrder":37,"source":"= AI agent endpoints\n:description: Agent CRUD, versions, analysis results, issues, prompt suggestions, test cases and Retell voices.\n\n== Agents\n\n=== `GET /api/ai-agents`\n\nFilters:: `type` (`analysis`, `caller`, `sms`, `other`), `llm_model_type` (`gpt`, `claude`)\n\n=== `POST /api/ai-agents`\n\nCreates an agent and its first `AiAgentVersion`. For `caller` agents the record is mirrored to Retell\nAI via `RetellAgentService`, and `retellAgentId` is stored.\n\n=== `+GET /api/ai-agents/{id}+`\n\nAgent with its latest version.\n\n=== `+PATCH /api/ai-agents/{id}+`\n\nUpdates the agent. Changes to a caller agent are pushed to Retell so the platform and the database do\nnot drift.\n\n=== `+DELETE /api/ai-agents/{id}+`\n\nDeletes the agent and, for caller agents, the Retell agent.\n\n=== `+POST /api/ai-agents/{id}/duplicate+`\n\nCopies the agent and its latest version — the safe way to experiment without editing a production\nagent.\n\n== Versions\n\n=== `+GET /api/ai-agents/{id}/versions+`\n\nLists versions. `version` selects one.\n\n=== `+POST /api/ai-agents/{id}/versions+`\n\nPublishes a new version, incrementing `version` and repointing `AiAgent.latestVersionId`. Versions are\nimmutable, which is what lets a call be replayed against the exact prompt that produced it.\n\n== Analysis results\n\n=== `GET /api/ai-agents/analysis`\n\nPaginated `CallAiAgentAnalysisResult` rows for the workspace. `page` (default 1), `pageSize`\n(default 20, max 100).\n\nFilters:: `aiAgentId`, `campaignId`, `hasIssues`, `dateFrom`, `dateTo`\n\n`dateFrom` / `dateTo` filter the *analysis* `createdAt`, not the call's.\n\n[source,json]\n----\n{\n  \"data\": [\n    {\n      \"id\": \"uuid\",\n      \"call_id\": \"uuid\",\n      \"call_created_at\": \"2026-01-01T00:00:00.000Z\",\n      \"created_at\": \"2026-01-02T00:00:00.000Z\",\n      \"target_phone_number\": \"+1234567890\",\n      \"campaign_id\": \"uuid\",\n      \"campaign_name\": \"Campaign Name\",\n      \"ai_agent_id\": \"uuid\",\n      \"ai_agent_name\": \"Analysis Agent\",\n      \"agent_score\": 8,\n      \"correct_disposition\": true,\n      \"suggested_disposition\": null,\n      \"summary\": \"…\",\n      \"issues\": []\n    }\n  ],\n  \"total\": 100,\n  \"page\": 1,\n  \"pageSize\": 20\n}\n----\n\nBoth timestamps are returned because they answer different questions: when the conversation happened\nversus when it was last graded.\n\n== Issues\n\n=== `GET /api/ai-agents/issues`\n\nFlattens the `issues` arrays from every analysis result into a searchable list.\n\nFilters:: `name`, `priority` (`critical`, `high`, `low`), `agentName`, `agentVersion`, `dateFrom`,\n`dateTo`, `page`, `pageSize`\n\n=== `POST /api/ai-agents/issues/suggest-prompt`\n\nRuns `PromptSuggestionService` to propose a prompt revision that addresses the selected issue.\n\n== Test cases\n\n=== `GET /api/test-cases`\n\nFilters:: `search`, `disposition`, `hasRecording`, `page`, `limit`\n\n=== `POST /api/test-cases`\n\nCreates a case, optionally from a `sourceCallId`, capturing the transcript and the ground-truth fields.\n\n=== `+GET|PATCH|DELETE /api/test-cases/{id}+`\n\n=== `POST /api/test-cases/run`\n\nRuns cases against a caller agent version through `TestCaseRunnerService`.\n\n[source,typescript]\n----\ninterface TestCaseRunResult {\n  testCaseId: string\n  testCaseName: string | null\n  recordingUrl: string | null\n  passed: boolean          // true only when every evaluated field passes\n  skipped: boolean         // true when the transcript is missing\n  fields: Array<{\n    field: string\n    expected: string | boolean | null\n    predicted: string | boolean | null\n    pass: boolean | null   // null = ground truth not asserted, field excluded\n  }>\n  error?: string\n}\n----\n\nEvaluated fields are the intersection of the post-call analysis schema and the test-case ground-truth\ncolumns: `disposition`, `next_action`, `call_screening_detected`, `is_amazon_seller`. String\ncomparison is case-insensitive; booleans are strict.\n\n=== `POST /api/test-cases/suggest-prompt`\n\nProposes a prompt revision from failing cases.\n\n== Retell voices\n\n=== `GET /api/retell/voices`\n\nProxies Retell's voice catalogue for the agent editor. Option lists for voice models, LLM models and\nexpressive emotion tags live in `lib/retellai/agent-config.ts`.\n"},{"component":"dialer-v2","module":"api","path":"api:contacts.adoc","title":"Contact and registry endpoints","description":"Contacts, sellers, the phone and email registries, and the cross-campaign target list.","url":"https://docx.payability.com/dialer-v2/api/contacts.html","navOrder":38,"source":"= Contact and registry endpoints\n:description: Contacts, sellers, the phone and email registries, and the cross-campaign target list.\n\n== Contacts\n\n=== `GET /api/contacts`\n\n`page`, `pageSize`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Free text across name, email, phone and company\n| `name`, `email`, `phone` | Field-specific partial matches\n| `hubspotId` | Exact `hubspotContactId`\n| `sellerId` | Contacts belonging to one seller\n| `source` | `campaign`, `hubspot`, `import`, `manual`\n| `lifecycleStage` | `ContactLifecycleStage`\n| `leadStatus` | `ContactLeadStatus`\n| `stopCalling` | Boolean\n| `hasEmail` | Only contacts with an address\n| `hasSupplierKey` | Only contacts linked to a supplier record\n|===\n\n=== `+GET /api/contacts/{id}+`\n\nSingle contact with its seller and registry links.\n\n=== `+GET /api/contacts/{id}/stats+`\n\nAggregate counts for the contact detail header — calls, emails, activities, tasks.\n\n=== `+GET /api/contacts/{id}/calls+`\n\nPaginated calls for the contact (`page`, `pageSize`).\n\n=== `+GET /api/contacts/{id}/activities+`\n\nPaginated `Activity` rows (`page`, `pageSize`).\n\n=== `+GET /api/contacts/{id}/emails+` / `+PATCH /api/contacts/{id}/emails/{emailId}+`\n\nThe contact's messages, and per-message updates (read, archive, category).\n\n=== `+GET /api/contacts/{id}/phones+` / `+PATCH /api/contacts/{id}/phones/{phoneId}+`\n\nThe contact's `TargetPhoneNumbers` rows, and per-row updates — this is where an operator blocks a\nsingle number without blocking the contact.\n\n=== `POST /api/contacts/crm-queue`\n\nPushes the contact onto the CRM SQS queue on demand. The manual escape hatch when an automatic sync\ndid not fire.\n\n== Sellers\n\n=== `GET /api/sellers`\n\n`page`, `pageSize`.\n\nFilters:: `search`, `platform` (`amazon`, `walmart`, `shopify`), `isSuspended`, `hubspotSyncStatus`\n\n== Suppliers\n\n=== `GET /api/suppliers`\n\n`page`, `pageSize`. Response includes `facets` (distinct status values) for filter dropdowns.\nEach row includes `contact_id` when a workspace `Contact` shares the same `supplier_key`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Name, company, email, phone, supplier key, store, merchant token\n| `api_status` | Exact `apiStatus`. `__none__` matches unset\n| `access_status` | Exact `accessStatus`. `__none__` matches unset\n| `cancelation_status` | Exact `cancelationStatus`. `__none__` matches unset\n| `status` | Exact `status`. `__none__` matches unset\n| `payability_status` | Exact `payabilityStatus`. `__none__` matches unset\n|===\n\n=== `+GET /api/suppliers/{id}+`\n\nSingle supplier including `extra` JSON. 404 when missing in the active workspace.\n\n== Phone registry\n\n=== `GET /api/target-phone-numbers`\n\nPaginated `TargetPhoneNumbers` for the workspace. `page` (default 1), `pageSize` (default 25, max 200).\nBacks `/targets/phone-numbers`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Phone number or carrier name, case-insensitive substring\n| `is_blocked` | `true` / `false`\n| `risk_level` | `low`, `medium`, `high`, `critical`\n| `is_active` | `true` / `false`\n| `include_deleted` | `true` to include soft-deleted rows (excluded by default)\n|===\n\n[source,json]\n----\n{\n  \"data\": [\n    {\n      \"id\": \"uuid\",\n      \"phone_number\": \"+1******890\",\n      \"subscription_id\": \"uuid\",\n      \"contact_id\": \"uuid\",\n      \"contact_email\": \"user@example.com\",\n      \"country_code\": \"1\",\n      \"carrier_name\": \"Example Carrier\",\n      \"mobile_country_code\": null,\n      \"mobile_network_code\": null,\n      \"line_type\": \"mobile\",\n      \"risk_level\": \"low\",\n      \"is_active\": true,\n      \"is_deleted\": false,\n      \"is_blocked\": false,\n      \"sync_twilio\": false,\n      \"sync_hubspot\": false,\n      \"hubspot_synced_at\": null,\n      \"created_at\": \"2026-01-01T00:00:00.000Z\",\n      \"updated_at\": \"2026-01-01T00:00:00.000Z\"\n    }\n  ],\n  \"total\": 100,\n  \"page\": 1,\n  \"pageSize\": 25\n}\n----\n\nPhone numbers are *masked* in the response (`lib/utils/mask-phone.ts`).\n\nOnly rows with `riskLevel` of `low` or null, not blocked and not deleted, are dialable — see\nxref:domain:contacts.adoc#registries[Registries].\n\n== Email registry\n\n=== `GET /api/target-emails`\n\nPaginated `TargetEmails` for the workspace. `page`, `pageSize`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Address substring\n| `is_active` | `true` / `false`\n| `is_blocked` | `true` / `false`\n| `newsletter_opt_out` | `true` / `false`\n| `include_deleted` | `true` to include soft-deleted rows\n|===\n\nAn address is suppressed when any of `newsletterOptOut`, `marketingOptOut` or `isBlocked` is set. See\nxref:domain:email-deliverability.adoc#suppression[Suppression].\n\n== Cross-campaign targets\n\n=== `GET /api/targets`\n\nPaginated `CampaignTarget` rows across every campaign in the workspace.\n\nParameters:: `campaignId`, `state`, `search`, `page`, `pageSize`\n\n`/targets` in the dashboard redirects to `/targets/phone-numbers`; `/targets/emails` is a placeholder\nfor a future email-targets view — email recipients remain under *Email campaigns*.\n"},{"component":"dialer-v2","module":"api","path":"api:email-campaigns.adoc","title":"Email campaign endpoints","description":"Email campaign CRUD, recipients, variations, duplication and the broadcast trigger.","url":"https://docx.payability.com/dialer-v2/api/email-campaigns.html","navOrder":39,"source":"= Email campaign endpoints\n:description: Email campaign CRUD, recipients, variations, duplication and the broadcast trigger.\n\n== Campaigns\n\n=== `GET /api/email-campaigns`\n\nEmail campaigns (`type = \"email\"`) with statistics from the `v_email_campaign_stats` materialized\nview: `total_count`, `valid_count`, `pending_count`, `queued_count`, `requested_count`,\n`sent_count`, `delivered_count`, `opened_count`, `clicked_count`, `bounced_count`,\n`suppressed_count`, `unsubscribed_count`, `blocked_count`. `opened_count` includes unsubscribed\nrecipients, and `delivered_count` excludes bounced and suppressed sends. See\nxref:domain:email-campaigns.adoc#_campaign_statistics[Campaign statistics] for each definition.\n\n=== `POST /api/email-campaigns`\n\nCreates the campaign with `type = \"email\"`. Accepts the email configuration columns —\n`schedulerIntervalSeconds`, `emailsPerHour`, `maxEmailsPerDay`, `emailsPerVariationPerHour`,\n`emailWindowStart`, `emailWindowEnd`, `emailAllowWeekends`, `resendTopicId`, `templateId`,\n`segmentId`, `description`, `goal`. Optional `segment_id` (validated before insert) is stored\non the campaign and queues `email-campaign/import-segment.requested` after the draft exists.\n\n=== `+GET /api/email-campaigns/{id}+`\n\nSingle campaign with statistics.\n\n=== `+PATCH /api/email-campaigns/{id}+`\n\nPartial update, including `status` — setting `paused` stops the next `pg_cron` tick from sending.\n`completed` is accepted only when the campaign is currently `paused`; any other current status returns 400.\nCompleted campaigns are not broadcast.\nOptional `segment_id` (`null` to clear) updates the assigned segment only; it does not re-import members.\n\n=== `+POST /api/email-campaigns/{id}/duplicate+`\n\nSynchronously creates a `draft` copy named `\"<name> (copy)\"` with the source's email configuration\n(`description`, `goal`, `schedulerIntervalSeconds`, `emailsPerHour`, `maxEmailsPerDay`,\n`emailsPerVariationPerHour`, `emailWindowStart`, `emailWindowEnd`, `emailAllowWeekends`), creates a\n*fresh* Resend topic for it (best-effort), then emits `email-campaign/duplicate.requested` and returns\n`201` with the new campaign.\n\nRecipients are copied asynchronously by the `emailCampaignDuplicate` Inngest function, which pages\nthrough the source's valid targets in chunks. A large recipient list would time out a single request, so\nonly the campaign shell is created inline. The copy starts in `draft`, so it cannot begin sending before\nits recipients have arrived. See xref:workflows:email.adoc[].\n\n=== `+POST /api/email-campaigns/{id}/send+`\n\nSends to a specific recipient or a small set — the manual path used for testing a variation, distinct\nfrom the scheduled broadcast.\n\n=== `+POST /api/email-campaigns/{id}/broadcast+`\n\n*Internal only* — `Authorization: Bearer <INTERNAL_API_SECRET>`. Called by `pg_cron` on the campaign's\n`schedulerIntervalSeconds`.\n\nRuns one tick of `EmailCampaignService.processBroadcast` and returns:\n\n[source,json]\n----\n{\n  \"emailsEnqueued\": 25,\n  \"throttled\": false,\n  \"throttleReason\": null,\n  \"variationGenerationRequested\": false,\n  \"message\": \"…\"\n}\n----\n\n`throttled: true` means the condition will clear on its own (a cap window resetting).\n`throttled: false` with zero enqueued means an operator has to act — no active variations, or no\nactive sending accounts. See xref:domain:email-campaigns.adoc[].\n\n== Recipients\n\n=== `+GET /api/email-campaigns/{id}/targets+`\n\n`page` (default 1), `page_size`… — note this endpoint uses `pageSize` (default 25, max 500).\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Address or name substring\n| `has_clicked` | `true` / `false` — filters on `clickedAt`, because there is no `clicked` status\n| `category` | `all`, `none` (uncategorised), or an `EmailTargetCategory` value\n| `statuses` | Repeatable `EmailTargetStatus` values, parsed by `parseEmailTargetStatusQueryList`\n|===\n\n=== `+POST /api/email-campaigns/{id}/targets+`\n\nImports recipients. Contacts are upserted first, so `EmailCampaignTarget.contactId` and `emailId` are\nalways populated. Validated by `ImportEmailTargetsSchema` or `ImportContactIdsSchema` depending on the\npayload shape.\n\n=== `+PATCH /api/email-campaigns/{id}/targets/{tid}+`\n\nUpdates one recipient — status corrections and category overrides.\n\n=== `+POST /api/email-campaigns/{id}/targets/from-registry+`\n\nImports from the `TargetEmails` registry by filter, rather than from an uploaded file.\n\n=== `+GET /api/email-campaigns/{id}/targets/from-registry/resolve+`\n\nPreviews how many registry rows a filter would import, so an operator can check the blast radius first.\n\n=== `+POST /api/email-campaigns/{id}/targets/from-segment+`\n\nQueues a one-shot copy of a segment's current active members. Body `{ \"segment_id\": \"<uuid>\" }`.\nReturns `202` `{ queued, campaign_id, segment_id }`. Also stores `segmentId` on the campaign\n(assignment only; members are still copied asynchronously). The campaign must be `type = email`\nand owned; the segment must be active and in the same subscription. See xref:workflows:email.adoc[].\n\n=== `+GET /api/email-campaigns/{id}/targets/export+`\n\nExports the recipient list with current statuses.\n\n== Campaign variations\n\n=== `+GET /api/email-campaigns/{id}/variations+`\n\nThe campaign's own variations.\n\n=== `+POST /api/email-campaigns/{id}/variations+`\n\nCreates a variation *and* its Resend template. This is also the path used when copying a campaign-less\ntemplate into a campaign.\n\n=== `+PATCH /api/email-campaigns/{id}/variations/{vid}+`\n\nUpdates content and re-syncs the Resend template.\n\n=== `+DELETE /api/email-campaigns/{id}/variations/{vid}+`\n\nRemoves a variation. Prefer setting `status = archived` when the variation has already been sent, so\nhistorical recipients keep a resolvable `variationId`.\n\nThe reusable template library is at xref:email-infrastructure.adoc[].\n"},{"component":"dialer-v2","module":"api","path":"api:email-infrastructure.adoc","title":"Email content and domain endpoints","description":"Reusable templates, the variation library, domains, sending accounts and unsubscribe.","url":"https://docx.payability.com/dialer-v2/api/email-infrastructure.html","navOrder":40,"source":"= Email content and domain endpoints\n:description: Reusable templates, the variation library, domains, sending accounts and unsubscribe.\n\n== Reusable templates\n\nCampaign-less `EmailVariation` rows (`campaignId = NULL`). Managed at `/email-campaigns/templates`.\n\n=== `GET /api/email-templates`\n\n`page`, `pageSize`.\n\n=== `POST /api/email-templates`\n\nCreates a template. Templates are database-only — no Resend template is created until the template is\ncopied into a campaign.\n\n=== `+PATCH /api/email-templates/{id}+` / `+DELETE /api/email-templates/{id}+`\n\nBecause every send and selection query filters on `campaignId`, a template can never be sent directly.\n\n== Variation library\n\n=== `GET /api/email-variations`\n\nA cross-campaign read-only view of variations. `page`, `pageSize`. Backs\n`/email-campaigns/variations`.\n\n=== `+PATCH /api/email-variations/{id}+`\n\nUpdates a variation from the library view.\n\nSerialisation lives in `lib/serializers/email-variation.ts`.\n\n== Domains\n\nA `Domain` is a Resend-verified sending domain that owns one or more email accounts and holds the\nshared daily cap.\n\n=== `GET /api/email-domains`\n\nDomains with their accounts and today's usage (`daily_sent_total`, the sum of the accounts' sends\nfor the current UTC day). An account whose `dailySentDate` is an earlier day reports\n`daily_sent_count: 0`.\n\n=== `POST /api/email-domains`\n\nCreates a domain. Supply the `resendDomainId` obtained from\n`GET /api/email-domains/resend-domains`.\n\n=== `+PATCH /api/email-domains/{id}+` / `+DELETE /api/email-domains/{id}+`\n\nUpdate `dailyCap`, `isActive`, `warmupStatus`, or remove the domain.\n\n=== `GET /api/email-domains/resend-domains`\n\nLists verified domains from Resend, so the UI can offer real domain ids rather than free text.\n\n=== `GET /api/email-domains/stats`\n\nPer-account and per-domain counters: `sentCount`, `receivedCount`, `openedCount`, `clickedCount`,\n`bouncedCount`, `unsubscribedCount`, `dailySentCount`, `totalSentCount`.\n\n=== `GET /api/email-domains/summary`\n\nRolled-up figures for the dashboard header. `sent_today` counts only today's (UTC) sends.\n`daily_capacity` adds, per active domain, the lower of the domain cap and its active accounts' summed\ncaps.\n\nSerialisation lives in `lib/serializers/email-domain.ts`.\n\n== Sending accounts\n\n`EmailSendingDomain` — a concrete `From` address under a parent domain. Called an \"email account\" in\nthe UI.\n\n=== `GET /api/sending-domains`\n\n`domain_id` filters to one parent domain.\n\n=== `POST /api/sending-domains`\n\nCreates an account: `domainId`, `domain`, `fromName`, `fromEmail`, `resendDomainId`, `dailyCap`,\n`isActive`, `isVerified`. The `fromEmail` host must match the parent `Domain.name` exactly.\n\n=== `+PATCH /api/sending-domains/{id}+` / `+DELETE /api/sending-domains/{id}+`\n\nOnly *active* accounts under an *active* domain, under both daily caps, are eligible for a send. See\nxref:domain:email-deliverability.adoc[].\n\n== Unsubscribe\n\n=== `GET /api/unsubscribe?token=<base64url>`\n\nPublic — no authentication. Renders the confirmation page. `token` is the `EmailCampaignTarget` id\nencoded as base64url.\n\n=== `POST /api/unsubscribe`\n\nThe RFC 8058 one-click endpoint referenced by the `List-Unsubscribe-Post` header.\n\nEffect:: Sets `TargetEmails.newsletterOptOut` with `newsletterOptOutAt`, which suppresses the address\nacross *every* campaign in the workspace, and publishes `Contact.Unsubscribe` to the CRM queue with\n`legal_basis` = `LEGITIMATE_INTEREST_OTHER` and the explanation constant from `lib/constants.ts`.\n\nSuppression is per address, not per campaign — an unsubscribe from one campaign stops all of them.\n\n== Daily reset\n\nThere is no HTTP endpoint. The Inngest function `sendingDomainDailyResetCron` runs at 00:00 UTC,\nzeroes `dailySentCount` and clears `dailySentDate` on every account. `totalSentCount` is never reset.\nThe daily cap already ignores counters from an earlier UTC day, so the reset is a backstop. See\nxref:webhooks.adoc#cron[Cron jobs].\n"},{"component":"dialer-v2","module":"api","path":"api:emails.adoc","title":"Email inbox endpoints","description":"Listing, reading and sending individual email messages.","url":"https://docx.payability.com/dialer-v2/api/emails.html","navOrder":41,"source":"= Email inbox endpoints\n:description: Listing, reading and sending individual email messages.\n\nBacked by the `Emails` model. See xref:domain:email-inbox.adoc[].\n\n== `GET /api/emails`\n\n`page` (default 1), `page_size` (default 25, max 100).\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `direction` | `all` (default) or an `EmailDirection` value\n| `inbox_category` | `all` (default) or an `EmailInboxCategory` value\n| `search` | Free text over subject, body and addresses\n| `include_archived` | `true` or `1` to include archived messages\n| `sending_domain` | `all`, `none` (no `sendingDomainId`), or an account UUID\n| `campaign_id` | Restrict to one campaign\n| `target_category` | `all` or an `EmailTargetCategory` value\n|===\n\nEach row:\n\n[source,json]\n----\n{\n  \"id\": \"uuid\",\n  \"direction\": \"received\",\n  \"inbox_category\": \"reply\",\n  \"thread_id\": \"uuid\",\n  \"external_id\": \"resend_email_id\",\n  \"to_email\": \"alex@payability.com\",\n  \"from_email\": \"prospect@example.com\",\n  \"subject\": \"Re: Quick question\",\n  \"body\": \"plain text\",\n  \"body_html\": \"<p>…</p>\",\n  \"opened\": false,\n  \"read_at\": null,\n  \"archived_at\": null,\n  \"sent_at\": null,\n  \"created_at\": \"2026-01-01T00:00:00.000Z\",\n  \"campaign_id\": \"uuid\",\n  \"campaign_name\": \"Campaign Name\",\n  \"contact_id\": \"uuid\",\n  \"contact_name\": \"John Doe\",\n  \"target_category\": \"interested\",\n  \"reply_count\": 2,\n  \"is_read\": false\n}\n----\n\n`opened` is the recipient's tracking pixel; `is_read` (derived from `read_at`) is whether an operator\nhas read it. `reply_count` is the number of messages in the thread.\n\n== `+GET /api/emails/{id}+`\n\nThe full thread, ordered oldest first. Each message returns `id`, `direction`, `from_email`,\n`to_email`, `subject`, `body`, `body_html`, `sending_domain_id`, `sent_at`, `created_at`.\n\n== `+PATCH /api/emails/{id}+`\n\nUpdates in-app state — read, archived and category.\n\n== `POST /api/emails`\n\nSends a one-off email, used for manual replies. Validated by `PostEmailSchema`.\n\nEffects:\n\n* Sends through Resend with a `Reply-To` derived from the sending account\n  (`buildReplyToForSendingDomain`).\n* Writes an `Emails` row with `direction = sent`, reusing the thread's `threadId` when replying.\n* Writes an `Activity` row.\n* Converts the HTML body to plain text with `htmlToPlainText` so the message is searchable.\n"},{"component":"dialer-v2","module":"api","path":"api:sequences.adoc","title":"Sequence endpoints","description":"Sequence template CRUD and contact enrollment lifecycle.","url":"https://docx.payability.com/dialer-v2/api/sequences.html","navOrder":42,"source":"= Sequence endpoints\n:description: Sequence template CRUD and contact enrollment lifecycle.\n\nBusiness rules are in xref:domain:sequences.adoc[]; schemas in `lib/schema/sequences.ts`.\n\nSequence endpoints use `page_size`, not `pageSize`.\n\n== Templates\n\n=== `GET /api/sequence-templates`\n\n`page`, `page_size`.\n\nFilters:: `search`, `type` (`onboarding`, `re_engagement`, `not_connected_outreach`), `is_active`\n\nResponse:: `{ data, total, page, page_size }`, each template including its full `steps` array.\n\n=== `POST /api/sequence-templates`\n\nValidated by `CreateSequenceTemplateSchema`.\n\n[source,json]\n----\n{\n  \"name\": \"Onboarding — 5 touch\",\n  \"type\": \"onboarding\",\n  \"goal\": \"Book a demo\",\n  \"description\": \"…\",\n  \"call_campaign_default\": false,\n  \"email_campaign_default\": true,\n  \"sms_campaign_default\": false,\n  \"is_active\": true,\n  \"steps\": [\n    { \"channel\": \"email\", \"delay_hours\": 0, \"name\": \"Intro\", \"email_tone\": \"warm\", \"is_ai_generated\": true },\n    { \"channel\": \"wait\",  \"delay_hours\": 48, \"delay_unit\": \"hours\" },\n    { \"channel\": \"data_gate\",\n      \"delay_hours\": 0,\n      \"gate_bigquery_view\": \"outreach_app.contacts\",\n      \"gate_match_mode\": \"all\",\n      \"gate_conditions\": [{ \"field\": \"deal_id\", \"operator\": \"is_not_null\", \"value\": null }],\n      \"true_branch_step_index\": 5,\n      \"gate_retry_delay_hours\": 24,\n      \"gate_max_retries\": 3 },\n    { \"channel\": \"crm_event\",\n      \"delay_hours\": 0,\n      \"extra_configs\": {\n        \"event_type\": \"Contact.Update\",\n        \"body\": { \"hubspot_contact_id\": \"{{hubspot_contact_id}}\", \"last_touch\": \"{{now}}\" }\n      } }\n  ]\n}\n----\n\nValidation rules worth knowing:\n\n* `gate_conditions` operators are `is_not_null`, `is_null`, `equals`, `not_equals`, `gt`, `lt`,\n  `is_in`, `is_not_in`. `value` may be null only for the null checks; `is_in` / `is_not_in` take a\n  comma-separated list.\n* A `crm_event` step's `extra_configs` must satisfy `CrmEventConfigSchema`. `event_type` must be one of\n  the registry values, and the reserved key `event_type` inside `body` is rejected.\n* `delay_unit` is `hours` or `minutes` and is only meaningful for `wait`.\n\n=== `+GET /api/sequence-templates/{id}+`\n\nTemplate with steps.\n\n=== `+PATCH /api/sequence-templates/{id}+`\n\nValidated by `UpdateSequenceTemplateSchema`. Supplying `steps` replaces the step set. Existing\n`ContactSequenceStep` logs survive, because `templateStepId` is `onDelete: SetNull` — the link is\nsevered, the history kept.\n\n=== `+DELETE /api/sequence-templates/{id}+`\n\n=== `+POST /api/sequence-templates/{id}/duplicate+`\n\nDeep-copies the template and its steps.\n\n== Enrollments\n\n=== `GET /api/contact-sequences`\n\n`page`, `page_size`.\n\nFilters:: `search`, `status` (`pending`, `active`, `paused`, `completed`, `abandoned`, `exited`)\n\nEach row includes `contact_name`, `contact_email`, `contact_company`, `template_name`,\n`template_type`, `current_step_index`, `total_steps`, `next_step_at` and the lifecycle timestamps.\n\n=== `POST /api/contact-sequences`\n\nValidated by `EnrollContactSequenceSchema`.\n\n[source,json]\n----\n{\n  \"contact_id\": \"uuid\",\n  \"template_id\": \"uuid\",\n  \"campaign_id\": null,\n  \"campaign_target_id\": null,\n  \"email_campaign_target_id\": null\n}\n----\n\n`SequenceExecutionService.enrollContact` enforces one *active* sequence per contact in application\ncode — a database unique constraint would wrongly block multiple completed or paused enrollments.\n\n=== `+GET /api/contact-sequences/{id}+`\n\nFull detail: the enrollment, its `execution_id` (the Inngest run id), the template with steps, and the\n`ContactSequenceStep` timeline. Each log entry carries `status`, `outcome`, `gate_retry_count`,\n`executed_at`, the artifact ids (`call_id`, `email_id`, `follow_up_task_id`, `activity_id`) and the raw\n`response` payload.\n\n=== `+POST /api/contact-sequences/{id}/start+`\n\nSets the enrollment active and sends `sequence/execute`, which claims the run by stamping\n`executionId`.\n\n=== `+POST /api/contact-sequences/{id}/stop+`\n\nSends `sequence/cancel`. The `sequenceExecute` function declares\n`cancelOn: [{ event: \"sequence/cancel\", match: \"data.contactSequenceId\" }]`, so the run is torn down\nimmediately — including an in-progress `step.sleep`.\n\n=== `+POST /api/contact-sequences/{id}/restart+`\n\nResets progress and starts a new run. A terminal sequence is refused by `startExecution` unless it has\nbeen restarted through this endpoint.\n"},{"component":"dialer-v2","module":"api","path":"api:people.adoc","title":"Sales rep and task endpoints","description":"Rep CRUD, profile matching, and the follow-up task board.","url":"https://docx.payability.com/dialer-v2/api/people.html","navOrder":43,"source":"= Sales rep and task endpoints\n:description: Rep CRUD, profile matching, and the follow-up task board.\n\nSee xref:domain:sales-reps.adoc[] for the rules.\n\n== Sales reps\n\n=== `GET /api/sales-reps`\n\n`page`, `pageSize`.\n\nFilters:: `search`, `status`\n\n=== `POST /api/sales-reps`\n\nCreates a rep: `name`, `email`, and optionally `profileId`, `hubspotOwnerId`, `nooksUserId`,\n`activeListId`, `hubspotCallSegmentId`.\n\nUnique on `(subscriptionId, email)`. A rep can exist before having a Supabase account — `profileId` is\noptional.\n\n=== `+GET|PATCH|DELETE /api/sales-reps/{id}+`\n\nPopulate `nooksUserId` for Nooks call attribution to work by its primary path; email matching is only a\nfallback.\n\n=== `+GET /api/sales-reps/{id}/calls+`\n\nCalls attributed to the rep (`page`, `pageSize`) — `Call.salesRepId`, populated by the Nooks handler.\n\n=== `+GET /api/sales-reps/{id}/tasks+`\n\nThe rep's follow-up tasks (`page`, `pageSize`).\n\n=== `GET /api/sales-reps/profile-match?email=<email>`\n\nResolves a signed-in user to their `SalesRep` record, so the dashboard can show \"my tasks\" without the\nuser knowing their rep id.\n\n== Follow-up tasks\n\n=== `GET /api/follow-up-tasks`\n\nValidated by `FollowUpTasksListQuerySchema`. `page`, `pageSize`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Contact or note text\n| `status` | `pending`, `in_progress`, `completed`, `skipped`, `failed`\n| `type` | `call`, `email`, `sequence_enrollment`\n| `sales_rep_id` | Assignee\n| `due_filter` | Relative due-date bucket (overdue, today, upcoming)\n| `sort` | Sort order\n| `created_from`, `created_to` | Creation date range\n|===\n\nResponse:: `{ data, total, page, pageSize, stats }`\n\nThe `stats` block is computed in the same request as the page (`getStats(subscriptionId)`), so the task\nboard renders its counters without a second round trip.\n\n=== `POST /api/follow-up-tasks`\n\nValidated by `CreateFollowUpTaskSchema`: `salesRepId`, `contactId`, `type`, `dueAt`, `notes`, and\noptionally `campaignTargetId` or `contactSequenceId`.\n\n=== `+GET /api/follow-up-tasks/{id}+`\n\n=== `+PATCH /api/follow-up-tasks/{id}+`\n\nUpdates `status`, `dueAt`, `notes`, `escalationStage` and the per-platform sync fields. Completing a\ntask should also cancel its pending escalation run using the stored `inngestRunId`.\n\nRepository and API mapping: `lib/repositories/follow-up-task-repository.ts`\n(`mapFollowUpTaskToApi`).\n"},{"component":"dialer-v2","module":"api","path":"api:phone-numbers.adoc","title":"Phone number endpoints","description":"Outbound number pool listing, purchase, update and release.","url":"https://docx.payability.com/dialer-v2/api/phone-numbers.html","navOrder":44,"source":"= Phone number endpoints\n:description: Outbound number pool listing, purchase, update and release.\n\nModel and selection rules: xref:domain:phone-numbers.adoc[].\n\n== `GET /api/phone-numbers`\n\n`page`, `pageSize`. Note this route reads its parameters with single quotes in source, but the names are\nthe same.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `search` | Number substring\n| `state` | US state code\n| `areaCode` | Area code\n| `provider` | `external`, `retell_twilio`, `retell_telnyx`\n| `isActive` | `true` / `false`\n| `isDeleted` | `true` / `false`\n|===\n\n== `POST /api/phone-numbers`\n\nPurchases a number through MessagingApp and imports it into Retell using the carrier's SIP trunk and\ntermination URL from `EXTERNAL_PROVIDER_CONFIGS`.\n\nBody:: `state` or `areaCode`, and `provider` (defaults to `DEFAULT_EXTERNAL_PROVIDER`, i.e. `twilio`)\n\nThe route reads the provider registry dynamically, so adding a carrier requires no change here — only\na new entry in `EXTERNAL_PROVIDER_CONFIGS`.\n\nAutomatic purchasing during dialing is currently disabled; this endpoint is the manual path.\n\n== `+GET /api/phone-numbers/{id}+`\n\n== `+PATCH /api/phone-numbers/{id}+`\n\nUpdates `isActive`, `isDefault`, `smsEnabled`, `maxAllowedCallsPerDay`, `state` and cost fields.\n\nToggling `smsEnabled` is what makes a number eligible as the `from` address for outbound SMS, including\nthe `send_sms` MCP tool.\n\n== `+DELETE /api/phone-numbers/{id}+`\n\nReleases the number: removed from Retell, returned to the carrier, and marked `isReleased` with\n`releasedAt`. This is a soft delete — the row is retained so historical calls keep a resolvable\n`phoneNumberId`.\n\n== Scheduled maintenance\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Inngest function | Schedule | Effect\n\n| `phoneNumberDailyResetCron` | Daily 00:00 UTC | Zeroes `dailyCallCount` on every number\n| `phoneNumberOptimizerCron` | Monday 11:15 UTC | Releases numbers unused for 3+ days with fewer than 10 lifetime calls\n|===\n\nThese are not HTTP endpoints; invoke them from the Inngest dashboard to run them now. See\nxref:webhooks.adoc#cron[Cron jobs].\n"},{"component":"dialer-v2","module":"api","path":"api:reports.adoc","title":"Report and activity endpoints","description":"Reporting endpoints, their source views, and the unified activity feed.","url":"https://docx.payability.com/dialer-v2/api/reports.html","navOrder":45,"source":"= Report and activity endpoints\n:description: Reporting endpoints, their source views, and the unified activity feed.\n\nSchemas live in `lib/schema/reports.ts`; every response is parsed through them in the hook. Date\nfilters are anchored to America/New_York. See xref:domain:reporting.adoc[].\n\n== `GET /api/reports/daily-stats`\n\nSource:: `vm_main_campaign_reports` — a view over the `call_daily_stats` rollup table\nParameters:: `dateFrom`, `dateTo`\n\n[source,json]\n----\n{ \"data\": [ { \"created_date\": \"2026-01-15\", \"unique_calls\": 100, \"…\": 0 } ], \"totals\": { \"unique_calls\": 100 } }\n----\n\nVolume fields: `unique_calls`, `total_duration_m`, `billed_minutes`, `calls_picked_up`.\n\nDisposition fields: `ivr`, `gatekeeper`, `dial_failed`, `qualified_lead`, `call_back`,\n`not_qualified`, `not_a_seller`, `not_interested`, `wrong_num`, `voice_mail`, `ai_screening`, `busy`,\n`hangup_early`, `ai_issue`, `do_not_call`, `phone_provider_unavailable`, `dial_no_answer`,\n`user_declined`, `invalid_destination`, `dial_busy`, `user_hangup`, `error_retell`,\n`tel_provider_perm_denied`, `no_disposition`.\n\n`totals` is computed by the database, so the UI never re-sums the page.\n\n== `GET /api/reports/funnel`\n\nSource:: `main_required_summaries`\nParameters:: `dateFrom`, `dateTo`\n\n[source,json]\n----\n{\n  \"daily\": [ { \"created_date\": \"2026-01-15\", \"total\": 100, \"valid_conversation\": 40, \"hubspot_synced\": 12, \"follow_up_needed\": 8 } ],\n  \"aggregated\": { \"total\": 100, \"valid_conversation\": 40, \"hubspot_synced\": 12, \"follow_up_needed\": 8 }\n}\n----\n\n== `GET /api/reports/sankey-chart`\n\nSource:: `main_sankey_chart_view`\nParameters:: `dateFrom`, `dateTo`\n\n[source,json]\n----\n{\n  \"daily\": [ { \"created_date\": \"2026-01-15\", \"total_calls\": 100,\n               \"sankey_data\": { \"nodes\": [{ \"name\": \"Total_Calls\" }], \"links\": [{ \"source\": 0, \"target\": 1, \"value\": 40 }] } } ],\n  \"aggregated\": { \"total_calls\": 100, \"sankey_data\": { \"nodes\": [], \"links\": [] } }\n}\n----\n\n`source` and `target` are indices into `nodes`. Node colours come from `SANKEY_NODE_COLOR_MAP` in\n`lib/constants.ts`, so a disposition is the same colour in every chart.\n\n== `GET /api/reports/daily-agent-performance`\n\nSource:: `vm_main_daily_agent_performance_report` — a view over the `agent_daily_performance_stats` rollup table\nParameters:: `date`, or `dateFrom` and `dateTo`\n\nPer date and AI agent: `ai_agent_id`, `ai_agent_name`, `total_calls`, `total_analysis`, `avg_score`,\n`total_score`, `correct_dispositions`, `correct_disposition_pct`, `avg_qa_ai_agent_score`,\n`total_qa_ai_agent_score`, `total_critical_issues`, `hangup_calls`,\n`hangup_disposition_rate_pct`.\n\nResponse:: `{ \"data\": [...] }`\n\n== `GET /api/reports/phone-number-pickup-rate`\n\nSource:: `phone_number_pickup_rate`\nParameters:: `state`, `provider`\n\nPer-number pickup rate. The query joins `\"PhoneNumbers\"` — quoted, because that table keeps the\nPascalCase default name.\n\n== `GET /api/reports/email-campaign-daily`\n\nSource:: `public.v_email_campaign_daily_engagement`\nParameters:: `campaignId`, `dateFrom`, `dateTo`\n\n== `GET /api/reports/email-campaign-target-summary`\n\nRecipient status distribution over a date range, from `vm_email_campaign_target_summary`.\nEach day is the America/New_York date of `sentAt`. `total_opened` and `total_clicked` count\ntargets that are not bounced. The dashboard open and click rates divide those counts by\ndelivered mail (`total_sent` minus `total_bounced`).\nParameters:: `dateFrom`, `dateTo`\n\n== `GET /api/reports/ai-calling`\n\nSource:: Outcomes from BigQuery `hubspot4.v_calls_latest` for owner `86817215`, joined to `hubspot4.v_contacts_latest`, `hubspot4.v_deals_latest`, and `PayabilitySheets.v_supplier_summary_extended`. The Calls total on the page comes from `GET /api/reports/daily-stats` (`vm_main_campaign_reports`), because not every AI call is synced to HubSpot.\nParameters:: `dateFrom`, `dateTo` (YYYY-MM-DD). Missing values default to the last 30 Eastern days. Spans longer than 366 days return 400.\n\nAuth via `resolveSubscription()`. These views have no tenant column, so results are not filtered by\n`subscriptionId`.\n\nA deal counts only when `created_date` is on or after the contact's first AI call in the range.\nDeal won is stage `closedwon` (Active (Won)) or `1045206663` (Closed Won). `lost_reasons` counts people on a lost best deal, grouped by HubSpot lost reason. Funded is\n`first_purchase_date` on or after that first AI call. `deal_amount_total` is HubSpot deal amount\non won deals, not funded dollars. `v_deals_latest` must exist before deal columns return data.\n\nEach `warm_leads` row has `engagement_since` (first Warm Lead call in the range, or first AI call for\nwon-only contacts), `human_calls_since` (HubSpot calls by an owner other than `86817215`), and\n`emails_since` (from `hubspot4.v_emails_latest`). Both counts run from `engagement_since` to now.\n`v_emails_latest` must exist in BigQuery, otherwise the whole report request fails.\n\n== `GET /api/reports/reengagement-daily`\n\nSource:: BigQuery `outreach_app.v_re_engagement_daily_call_summary`\nParameters:: `dateFrom`, `dateTo` (YYYY-MM-DD list dates). Missing values default to the last 14\nEastern days. Spans longer than 90 days return 400.\n\nAuth via `resolveSubscription()`. The view has no tenant column, so results are not filtered by\n`subscriptionId`.\n\nResponse::\n\n[source,json]\n----\n{\n  \"date_from\": \"2026-08-24\",\n  \"date_to\": \"2026-09-07\",\n  \"daily\": [{ \"list_date\": \"2026-09-01\", \"segment_name\": \"Lost deal\", \"total_contacts\": 40, \"called_contacts\": 12, \"connected_contacts\": 3 }],\n  \"by_segment\": [{ \"segment_name\": \"Lost deal\", \"total_contacts\": 200, \"called_contacts\": 80, \"connected_contacts\": 15, \"calls_connected\": 10 }],\n  \"funnel\": { \"total_contacts\": 200, \"called_contacts\": 80, \"uncalled_contacts\": 120, \"connected_contacts\": 15, \"positive_calls_contacts\": 4 },\n  \"contact_funnel\": { \"total_contacts\": 40, \"called_contacts\": 18, \"uncalled_contacts\": 22, \"never_called_contacts\": 10, \"connected_contacts\": 6, \"positive_calls_contacts\": 2 }\n}\n----\n\n`by_segment` and `funnel` are summed entry counts in `lib/services/reengagement-daily-call-report-service.ts`. `contact_funnel` counts each HubSpot contact once for the same range.\nDates are bound as STRING and compared with `DATE(@dateFrom)` / `DATE(@dateTo)`.\n\n`daily` and `by_segment` include per-disposition call counts from the view (`calls_connected`,\n`calls_account_created`, `calls_api_connected`, `calls_hang_up`, and the rest of the mapped\nHubSpot IDs). The segment chart stacks those counts. `connected_contacts` uses the view's\nconversation-disposition `IN` list. `positive_calls_contacts` is Warm Lead, Follow up scheduled,\nAPI Connected Confirmed, or Account Created. `uncalled_contacts` is contacts on that list date\nwith no human call on the list date or the next day. `never_called_contacts` is contacts on that\nrow with no human call on or after their first list date. The view still supplies the date-window\ncounts; the query adds `never_called_contacts`.\n\n== `GET /api/reports/reengagement-contacts`\n\nContact-level rows for list dates in the requested range. `dialled` is that row’s own window\n(human call on the list date or the next day; owner `86817215` excluded). `ever_dialled` is true\nwhen any of the contact’s list dates, on or after their first list date, has such a call.\n`dialStatus` filters on `ever_dialled`. Parameters:: `dateFrom`, `dateTo`, `dialStatus` (`all`,\n`dialled`, `not_dialled`), `segment`, `search`, `page`, `pageSize`.\n\n`GET /api/reports/reengagement-contacts/history?hubspotContactId=` returns up to 100 list\nappearances for one HubSpot contact, newest first. Each history row’s `dialled` flag is that\ndate’s own window.\n\n== `GET /api/reports/reengagement-results`\n\nSource:: BigQuery `outreach_app.v_re_engagement_results_summary`\nParameters:: none (all-time aggregation)\n\nAuth via `resolveSubscription()`. The view has no tenant column, so results are not filtered by\n`subscriptionId`. The query returns one aggregated row of `COUNTIF` buckets — it does not select\ncontact fields.\n\nStatus matching is `LOWER(TRIM(...))`. API `needed` counts `needed` and `required`. Remainder\nbuckets are `other` (non-null unmatched) and `null`. `with_first_purchase` counts contacts whose\nsupplier has a non-null `first_purchase_date`. `attributed_wins` counts contacts whose\n`first_purchase_date` is on or after `first_listdate`.\n\nResponse::\n\n[source,json]\n----\n{\n  \"total_contacts\": 2547,\n  \"no_supplier\": 1559,\n  \"with_first_purchase\": 88,\n  \"attributed_wins\": 12,\n  \"api\": { \"confirmed\": 172, \"in_review\": 67, \"needed\": 341, \"other\": 1, \"null\": 1966 },\n  \"access\": { \"incomplete\": 295, \"rejected\": 65, \"pending\": 83, \"verified\": 138, \"other\": 0, \"null\": 1966 },\n  \"payability\": { \"pending\": 894, \"active\": 18, \"suspended\": 70, \"other\": 0, \"null\": 1565 }\n}\n----\n\n== `GET /api/activity`\n\nThe unified feed: `Activity` rows merged with `Call` rows.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Parameter | Filter\n\n| `type` | A single `ActivityType`\n| `types` | Comma-separated `ActivityType` values\n| `search` | Free text\n| `campaignId` | Restrict to one campaign\n| `dateFrom`, `dateTo` | Date range\n| `page`, `pageSize` | Pagination\n|===\n\nResponse:: `{ data, total, page, pageSize }`\n\nNormalisation is done by `lib/utils/map-activity-feed.ts`; campaign names are resolved in bulk by\n`lib/utils/resolve-activity-campaign-names.ts` rather than per row. Hook: `useActivity(params?)`.\n\n[NOTE]\n====\nViews prefixed `vm_` are materialised. If a report looks stale, check the view's refresh schedule\nbefore investigating the route.\n====\n"},{"component":"dialer-v2","module":"api","path":"api:webhooks.adoc","title":"Webhooks, cron and internal endpoints","description":"Inbound provider webhooks, scheduled jobs, the Inngest handler and internal-only routes.","url":"https://docx.payability.com/dialer-v2/api/webhooks.html","navOrder":46,"source":"= Webhooks, cron and internal endpoints\n:description: Inbound provider webhooks, scheduled jobs, the Inngest handler and internal-only routes.\n\nWebhook paths are excluded from middleware (`api/webhooks` is in the matcher's negative lookahead) and\nauthenticate themselves. Handler behaviour is documented in xref:events:index.adoc[].\n\n== Provider webhooks\n\n=== `POST /api/webhooks/event-logger`\n\nRetell AI call events.\n\nFlow::\n. Parse the payload and extract source, event type and `call.call_id`.\n. Ignore inbound calls as a safety measure.\n. Upsert a `WebhookEvent` row, unique on `(source, eventId)`.\n. Dispatch through `webhookEventDispatcher`.\n. Return `200`.\n\nRegistered event types:: `call_analyzed` only. The `call_started` registration is commented out in\n`lib/events/event-handler.ts`, so `call_started` payloads are stored but not handled.\n\nThere is no signature verification on this endpoint. Treat the URL as a secret and add verification\nbefore exposing it more widely.\n\n=== `POST /api/webhooks/resend`\n\nResend email events.\n\nFlow::\n. Verify the Svix signature when `RESEND_WEBHOOK_SECRET` is set; skipped when unset (development).\n. Upsert a `WebhookEvent` row, idempotent on the `svix-id` header.\n. Extract `data.email_id` as the correlation key.\n. Dispatch through `ResendWebhookDispatcher`.\n\nHandled:: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.opened`, `email.clicked`,\n`email.bounced`, `email.complained`, `email.unsubscribed`, `email.received`. See xref:events:resend.adoc[].\n\n=== `POST /api/webhooks/nooks`\n\nHuman-dialer call outcomes.\n\nFlow::\n. Optional secret check — `Authorization: Bearer` or `X-Nooks-Signature` against\n  `NOOKS_WEBHOOK_SECRET`.\n. Upsert a `WebhookEvent` row with `source = \"Nooks\"`, idempotent on `eventId`.\n. Dispatch through `NooksWebhookDispatcher`.\n\nHandled:: `call.logged`. See xref:events:nooks.adoc[].\n\n=== `POST /api/webhooks/hubspot`\n\nHubSpot CRM subscription notifications.\n\nFlow::\n. Validate the HubSpot HMAC (or legacy) signature using `HUBSPOT_WEBHOOK_CLIENT_SECRET`\n  (`lib/utils/hubspot/webhook-signature.ts`).\n. Normalise the payload (`lib/utils/hubspot/webhook-normalize.ts`).\n. Publish as `Crm.HubSpotWebhook` to the CRM queue via `publishCrmWorkflowEvent`.\n\n=== `POST /api/webhooks/telnyx`\n\nTelnyx AI assistant call events. Store only — no handlers are registered.\n\nFlow::\n. Require `TELNYX_PUBLIC_KEY`. Return `503` when it is unset.\n. Verify the Ed25519 signature (`telnyx-timestamp`, `telnyx-signature-ed25519`) over the raw body.\n  Return `401` when the signature is missing, invalid, or older than five minutes.\n. Upsert a `WebhookEvent` row with `source = \"Telnyx\"`, idempotent on `data.id`.\n. Dispatch through `TelnyxWebhookDispatcher`. Unknown event types are stored and return `200`.\n\nSee xref:events:telnyx.adoc[].\n\n=== `POST /api/webhooks/elevenlabs`\n\nElevenLabs Agents post-call events. Store only — no dispatcher or handlers.\n\nFlow::\n. Require `ELEVENLABS_WEBHOOK_SECRET`. Return `503` when it is unset.\n. Verify the `ElevenLabs-Signature` HMAC (`t=...,v0=...`) over the raw body. Return `401` when it is\n  missing, malformed, invalid, or more than 30 minutes old.\n. Parse the JSON body; return `400` when it is not a JSON object.\n. Upsert a `WebhookEvent` row with `source = \"ElevenLabs\"`, idempotent on `{type}:{conversation_id}`.\n  `post_call_audio` is stored without its base64 audio.\n\nSee xref:events:elevenlabs.adoc[].\n\n=== `POST /api/webhooks/five9`\n\nAccepts a Five9 payload and stores it for later processing.\n\n[WARNING]\n====\nThis route writes with `source = \"Nooks\"` and `eventType = \"five_9\"`, generating a random `eventId` and\n`providerCallId`. Because the id is random, replays are *not* idempotent, and the rows share the `Nooks`\nsource with genuine Nooks events. Filter on `eventType` to separate them. No dispatch happens — the\npayload is stored only.\n====\n\n[#cron]\n== Cron jobs\n\nThere are no HTTP cron endpoints. The global jobs are Inngest cron functions, which Inngest invokes\nthrough the signed `/api/inngest` endpoint. Schedules are in UTC.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Function | Schedule | Effect\n\n| `phoneNumberDailyResetCron` | `0 0 * * *` | `resetDailyCounts()` — zeroes `PhoneNumbers.dailyCallCount`\n| `sendingDomainDailyResetCron` | `0 0 * * *` | Zeroes `EmailSendingDomain.dailySentCount` and clears `dailySentDate`\n| `phoneNumberOptimizerCron` | `15 11 * * 1` | Releases numbers unused 3+ days with fewer than 10 lifetime calls, one Inngest step per number; returns `{ candidates, released, errors }`\n|===\n\nTo run one now, invoke it from the Inngest dashboard (or the Inngest dev server locally).\n\n== Internal endpoints\n\nGated by `verifyInternalSecret()` — `Authorization: Bearer <INTERNAL_API_SECRET>`. Fails closed when the\nsecret is unset.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Endpoint | Caller\n\n| `+POST /api/campaigns/{id}/broadcast+` | `pg_cron`, per call campaign\n| `+POST /api/email-campaigns/{id}/broadcast+` | `pg_cron`, per email campaign\n|===\n\n== Inngest\n\n=== `GET|POST|PUT /api/inngest`\n\nServes every registered function through `serve()` from `inngest/next`: `testContextCollection`,\n`processRequest`, `purchasePhoneNumber`, `callTarget`, `callEndedAnalysis`, `nooksCallAnalysis`,\n`emailSend`, `emailCampaignDuplicate`, `sequenceExecute`.\n\nAuthenticated by Inngest's own signing key (`INNGEST_SIGNING_KEY`). Adding a function means adding it to\nthe `functions` array — a function that is not listed will never run. See xref:workflows:index.adoc[].\n\n== Diagnostics\n\n=== `GET /api/sentry-example-api`\n\nThrows deliberately to verify Sentry wiring, paired with `/sentry-example-page`.\n"},{"component":"dialer-v2","module":"api","path":"api:mcp.adoc","title":"MCP tool server","description":"The JSON-RPC 2.0 Model Context Protocol endpoint, its tools and how to add one.","url":"https://docx.payability.com/dialer-v2/api/mcp.html","navOrder":47,"source":"= MCP tool server\n:description: The JSON-RPC 2.0 Model Context Protocol endpoint, its tools and how to add one.\n\n{product-name} exposes part of its calling, messaging and CRM capability as a\nhttps://modelcontextprotocol.io[Model Context Protocol] server, so external AI orchestrators can\ndiscover and invoke it as tools over one HTTP endpoint.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Property | Value\n\n| Endpoint | `POST /api/mcp` (JSON-RPC 2.0), `GET /api/mcp` (capability discovery)\n| Protocol version | `2024-11-05`\n| Transport | Stateless HTTP POST — no SSE, no per-connection state\n| Server info | `dialer-mcp` v`1.0.0`\n| Authentication | `Authorization: Bearer <MCP_API_SECRET>`\n| Tenant | `x-subscription-id: <subscriptionId>`, required on every call\n|===\n\n== Architecture\n\nA thin plugin layer over the existing services. It contains no business logic of its own — each tool\ndelegates to the same service the REST API uses.\n\n[source]\n----\nHTTP (JSON-RPC 2.0)      app/api/mcp/route.ts     auth + tenant + JSON parse\n  ↓ server.handleRequest()\nMcpServer                lib/mcp/server.ts        JSON-RPC dispatch, ZodError handling\n  ↓ registry.getTool()\nMcpToolRegistry          lib/mcp/registry.ts      name → implementation\n  ↓ tool.handle(input, ctx)\nIMcpTool implementations lib/mcp/tools/*.ts       Zod-validated, delegate down\n  ↓\nServices / Repositories  lib/services, lib/repositories\n----\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Concern | File\n\n| HTTP endpoint | `app/api/mcp/route.ts`\n| JSON-RPC server | `lib/mcp/server.ts`\n| Tool registry | `lib/mcp/registry.ts`\n| Type contracts (`IMcpTool`, `McpContext`, result helpers) | `lib/mcp/types.ts`\n| Tool bootstrap | `lib/mcp/tools/index.ts`\n| Tool implementations | `lib/mcp/tools/*.ts`\n| Input schemas | `lib/schema/mcp.ts`\n| Shared secret | `MCP_API_SECRET` in `lib/constants.ts`\n|===\n\nDesign decisions:\n\n* *Registry pattern* — the tool list is derived from registrations, and the registry throws on\n  duplicate names at module load, catching typos at startup rather than at call time. It is built once\n  per process.\n* *Centralised validation* — `McpServer` catches `ZodError`, so tool `handle()` methods stay free of\n  validation plumbing.\n* *Scoped context* — every tool receives `{ subscriptionId }` and scopes its database access to it.\n  A tool cannot bypass tenant isolation.\n* *Best-effort activity logging* — `send_sms` and `send_email` write `Activity` rows after a\n  successful send; a logging failure is recorded but does not fail the tool call.\n\n== Authentication and tenancy\n\n[source,bash]\n----\nAuthorization: Bearer <MCP_API_SECRET>\nx-subscription-id: <subscriptionId>\n----\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Condition | JSON-RPC code | HTTP\n\n| `MCP_API_SECRET` unset | `-32603` Server misconfiguration | 500\n| Missing or wrong bearer token | `-32600` Unauthorized | 401\n| Missing `x-subscription-id` | `-32600` | 400\n|===\n\nUnlike the dashboard routes, MCP does not use Supabase sessions or the `active_subscription_id`\ncookie — it authenticates with a shared secret and takes the tenant explicitly from the header.\n\n== JSON-RPC methods\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `initialize` | Handshake; returns `protocolVersion`, `serverInfo`, `capabilities`\n| `notifications/initialized` | Client acknowledgement; returns an empty result\n| `tools/list` | Definitions (name, description, JSON Schema) for every registered tool\n| `tools/call` | Dispatches to `params.name` with `params.arguments`\n|===\n\nUnknown methods return `-32601 Method not found`.\n\n=== Error codes\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Code | Meaning\n\n| `-32700` | Parse error — invalid JSON body\n| `-32600` | Invalid request, unauthorized, or missing tenant header\n| `-32601` | Method not found, or tool not found\n| `-32602` | Invalid params — e.g. missing `params.name` on `tools/call`\n| `-32603` | Internal error, or server misconfiguration\n|===\n\n[IMPORTANT]\n====\n*Input validation errors are not JSON-RPC errors.* When a tool's Zod schema rejects the arguments, the\nserver returns a *successful* JSON-RPC response whose `result` carries `isError: true` and a\nhuman-readable message. This lets the calling model read the message and correct itself instead of the\ntransport failing.\n====\n\n== Capability discovery\n\n`GET /api/mcp` needs no authentication and returns a human-readable document:\n\n[source,json]\n----\n{\n  \"name\": \"Dialer MCP Server\",\n  \"protocol\": \"Model Context Protocol\",\n  \"version\": \"2024-11-05\",\n  \"transport\": \"HTTP POST\",\n  \"authentication\": \"Bearer token (MCP_API_SECRET)\",\n  \"tenant_header\": \"x-subscription-id\",\n  \"tools_count\": 5,\n  \"tools\": [{ \"name\": \"list_ai_sales_agents\", \"description\": \"…\" }]\n}\n----\n\n== Tools\n\n=== `list_ai_sales_agents`\n\nList active caller-type AI agents in the workspace.\n\nInput:: none\nReturns:: `{ agents: [{ id, name, retell_agent_id, prompt }], total }`\nDelegates to:: `AiAgentRepository.findCallerAgentsBySubscription()`\n\n=== `list_call_history`\n\nRecent call history for a phone number, across every campaign in the workspace.\n\nInput:: `phone_number` (E.164, required), `limit` (1–50, default 50)\nReturns:: `{ phone_number, calls: [...], total }` — status, disposition, summary, transcript, AI agent,\nduration, recording URL, sentiment, callback info, timestamps\nDelegates to:: `CallRepository.findCallsByPhoneNumber()`\n\n=== `initiate_call`\n\nInitiate an outbound call for a campaign target.\n\nInput:: `target_id` (UUID, required). The campaign is read from the target record.\nFlow:: resolve the target → verify its campaign belongs to the caller's workspace → pre-validate\n(DNC, prior disposition, E.164) → `PhoneNumberCallingService.initiateCall()`\nReturns:: `{ call_id, provider_call_id, campaign_id, target_id, phone_number, phone_number_id,\nis_fallback_number }`, or a tool error when pre-validation skips the call\n\n[#send-sms]\n=== `send_sms`\n\nStart an outbound SMS conversation through the Retell SMS agent.\n\nRequired input:: `agent_name`, `company_name`, `customer_name`, `customer_phone` (E.164),\n`product_interest`, `lead_stage` (`new`, `contacted`, `engaged`, `qualified`, `stalled`), `goal`,\n`initial_message`\nOptional input:: `last_interaction_summary`, `customer_notes`,\n`conversation_history` (`[{ role, content, timestamp }]`), `follow_up_count`,\n`urgency` (`high`, `normal`, `low`)\nBehaviour:: the Retell agent is fixed to `RETELL_SMS_AGENT_ID`; the `from` number is auto-selected from\nthe workspace's SMS-enabled numbers; every field is forwarded as a Retell dynamic variable; an\n`Activity` row is written best-effort\nReturns:: `{ chat_id, chat_status, from_number, to }`\n\n=== `send_email`\n\nSend an email from `alex@payability.com` through Resend.\n\nInput:: `to` (email, required), `subject` (required), `body` (plain text or HTML, required),\n`hubspot_contact_id` (optional)\nBehaviour:: sends through Resend; when `hubspot_contact_id` is supplied, logs an email engagement on\nthat contact (best-effort); writes an `Activity` row and an `Emails` row (best-effort)\nReturns:: `{ success, email_id, to, subject, hubspot: { logged, engagement_id?, error? } }`\n\n== Adding a tool\n\n. Create `lib/mcp/tools/my-new-tool.ts` implementing `IMcpTool`:\n* a `definition` with `name`, `description` and `inputSchema` (a JSON-Schema subset);\n* `handle(input, context)` that validates with a Zod schema from `lib/schema/mcp.ts`, scopes every\n  query by `context.subscriptionId`, and delegates to a service or repository;\n* return through the `okResult(data)` / `errorResult(message)` helpers in `lib/mcp/types.ts`.\n. Add the input schema to `lib/schema/mcp.ts`.\n. Register it:\n\n[source,typescript]\n----\nexport const toolRegistry = new McpToolRegistry()\n  // …existing tools\n  .register(new MyNewTool())\n----\n\n== Examples\n\nDiscover tools:\n\n[source,bash]\n----\ncurl -sX POST https://<host>/api/mcp \\\n  -H \"Authorization: Bearer $MCP_API_SECRET\" \\\n  -H \"x-subscription-id: $SUBSCRIPTION_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/list\" }'\n----\n\nCall a tool:\n\n[source,bash]\n----\ncurl -sX POST https://<host>/api/mcp \\\n  -H \"Authorization: Bearer $MCP_API_SECRET\" \\\n  -H \"x-subscription-id: $SUBSCRIPTION_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 2,\n    \"method\": \"tools/call\",\n    \"params\": { \"name\": \"list_call_history\", \"arguments\": { \"phone_number\": \"+12025550100\", \"limit\": 10 } }\n  }'\n----\n\nSuccessful result — the payload is a JSON string inside MCP text content:\n\n[source,json]\n----\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 2,\n  \"result\": { \"content\": [{ \"type\": \"text\", \"text\": \"{ \\\"phone_number\\\": \\\"+12025550100\\\", \\\"calls\\\": [], \\\"total\\\": 0 }\" }] }\n}\n----\n\nTool-level error — still a JSON-RPC success:\n\n[source,json]\n----\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 2,\n  \"result\": {\n    \"content\": [{ \"type\": \"text\", \"text\": \"Invalid input — phone_number: phone_number must be in E.164 format (e.g. +12025550100)\" }],\n    \"isError\": true\n  }\n}\n----\n"},{"component":"dialer-v2","module":"events","path":"events:index.adoc","title":"Events","description":"Inbound webhook ingestion, the dispatcher registries and the handlers they route to.","url":"https://docx.payability.com/dialer-v2/events/index.html","navOrder":48,"source":"= Events\n:description: Inbound webhook ingestion, the dispatcher registries and the handlers they route to.\n\nEvery provider webhook follows the same shape: persist the raw payload, do the minimum synchronous\nwork, emit an Inngest event for anything expensive, and acknowledge.\n\n[mermaid]\n....\nsequenceDiagram\n  participant P as Provider\n  participant R as Route handler\n  participant DB as WebhookEvent\n  participant D as Dispatcher registry\n  participant H as Handler\n  participant I as Inngest\n\n  P->>R: POST /api/webhooks/…\n  R->>R: read raw body, verify signature\n  R->>DB: upsert on (source, eventId)\n  R->>D: dispatch(eventType, context)\n  D->>H: new Handler(context).handle()\n  H->>DB: update domain state\n  H->>I: emit follow-up event\n  R-->>P: 200 { received: true }\n....\n\n== Why this shape\n\nPersist before dispatch::\nThe raw payload is stored *first*, so a handler bug never loses an event. `WebhookEvent` is unique on\n`(source, eventId)`, making duplicate deliveries a no-op.\n\nAlways acknowledge::\nThe route returns `200` even when the handler throws. A `500` would make the provider retry work that\nmay have partially succeeded; the stored payload can be replayed deliberately instead.\n\nEmit, don't block::\nThe heavy work — AI analysis, retries, CRM sync — happens in Inngest functions triggered by events the\nhandler emits. The webhook response stays fast, and the expensive part becomes replayable and\nindependently retryable.\n\n== Dispatchers\n\nIndependent registries, one per provider, all following the same pattern: a `Map` from event type\nto handler constructor, populated in the dispatcher's constructor.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Provider | Dispatcher | Endpoint\n\n| Retell AI | `WebhookEventDispatcher` (`lib/events/event-handler.ts`) | `POST /api/webhooks/event-logger`\n| Resend | `ResendWebhookDispatcher` (`lib/events/resend-event-handler.ts`) | `POST /api/webhooks/resend`\n| Nooks | `NooksWebhookDispatcher` (`lib/events/nooks-event-handler.ts`) | `POST /api/webhooks/nooks`\n| Telnyx | `TelnyxWebhookDispatcher` (`lib/events/telnyx-event-handler.ts`) | `POST /api/webhooks/telnyx`\n|===\n\nAn unregistered event type is silently ignored — the raw row is still stored.\n\nHubSpot webhooks (`POST /api/webhooks/hubspot`) do not use a dispatcher; they are verified, normalised\nand republished to the CRM queue. Five9 payloads (`POST /api/webhooks/five9`) are stored only.\n\n== Event context\n\nEach dispatcher passes its handlers a context object.\n\nRetell (`lib/events/types.ts`):\n\n[source,typescript]\n----\ntype WebhookEventContext = {\n  source: string          // \"Retell\"\n  eventType: string       // \"call_started\" | \"call_analyzed\"\n  eventId: string\n  providerCallId: string\n  payload: any            // raw webhook payload\n}\n----\n\nResend (`lib/events/handlers/resend/base-handler.ts`):\n\n[source,typescript]\n----\ntype ResendWebhookContext = {\n  eventType: string        // e.g. \"email.clicked\"\n  eventId: string          // svix-id — the idempotency key\n  resendEmailId?: string   // data.email_id\n  toEmail?: string         // data.to[0]\n  payload: Record<string, unknown>\n}\n----\n\nTelnyx (`lib/events/handlers/telnyx/base-handler.ts`):\n\n[source,typescript]\n----\ntype TelnyxWebhookContext = {\n  eventType: string        // data.event_type\n  eventId: string          // data.id — the idempotency key\n  providerCallId: string | null  // conversation_id, else call_control_id\n  payload: Record<string, unknown>\n}\n----\n\nNo Telnyx handlers are registered yet. See xref:telnyx.adoc[].\n\n== Signature verification\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Provider | Mechanism | Behaviour when unset\n\n| Resend | Svix, `RESEND_WEBHOOK_SECRET` | Verification skipped (development mode)\n| HubSpot | HMAC / legacy signature, `HUBSPOT_WEBHOOK_CLIENT_SECRET` | Request rejected\n| Nooks | `Authorization: Bearer` or `X-Nooks-Signature`, `NOOKS_WEBHOOK_SECRET` | Verification skipped\n| Telnyx | Ed25519, `TELNYX_PUBLIC_KEY`, headers `telnyx-timestamp` and `telnyx-signature-ed25519` | Request rejected (`503`)\n| ElevenLabs | HMAC-SHA256, `ELEVENLABS_WEBHOOK_SECRET`, header `ElevenLabs-Signature` | Request rejected (`503`)\n| Retell | *none* | —\n| Five9 | *none* | —\n|===\n\nAll verifying routes read the body as raw text first, because signatures are computed over the exact\nbytes. See xref:security:secrets-and-webhooks.adoc[].\n\n== Emitted Inngest events\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Emitted by | Event\n\n| Retell `call_analyzed` handler | `call-target/call.ended`\n| Nooks `call.logged` handler | `nooks/call.logged`\n| Campaign broadcast, `callEndedAnalysis` double tap | `call-target/call.requested`\n| Email campaign broadcast | `email-campaign/send.requested`\n|===\n\nSee xref:workflows:index.adoc[].\n\n== Realtime broadcast\n\n`RetellCallEventHandler.broadcastEvent(event, campaignId, payload)` posts to Supabase Realtime's\nbroadcast API on the topic `campaign-<campaignId>`, letting the dashboard react live. It is skipped with\na warning when the Supabase URL or key is missing, so a misconfigured environment degrades rather than\nfailing the webhook.\n"},{"component":"dialer-v2","module":"events","path":"events:retell.adoc","title":"Retell AI events","description":"The call_analyzed handler — extraction, state decision, CRM hand-off and the emitted Inngest event.","url":"https://docx.payability.com/dialer-v2/events/retell.html","navOrder":49,"source":"= Retell AI events\n:description: The call_analyzed handler — extraction, state decision, CRM hand-off and the emitted Inngest event.\n\nEndpoint:: `POST /api/webhooks/event-logger`\nDispatcher:: `WebhookEventDispatcher` in `lib/events/event-handler.ts`\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Event type | Handler | Status\n\n| `call_analyzed` | `RetellCallCompletedHandler` (`lib/events/handlers/call-ended.ts`) | Registered\n| `call_started` | `RetellCallStartedHandler` (`lib/events/handlers/call-started.ts`) | *Not registered* — the line is commented out\n|===\n\n[IMPORTANT]\n====\nTwo implementations of `RetellCallCompletedHandler` exist:\n\n* `lib/events/handlers/call-ended.ts` — a single class holding extraction, the state machine and CRM\n  hand-off.\n* `lib/events/handlers/call-ended/` — a decomposed version with `CallDataParser`,\n  `StateDecisionEngine`, `PrismaDatabaseAdapter` and `HubspotEligibilityChecker`.\n\n`lib/events/handlers/index.ts` re-exports `\"./call-ended\"`, which resolves to the *file*, not the\ndirectory. The single-class implementation is therefore the one that runs; the decomposed version is\nreachable only through `createRetellCallCompletedHandler()`, which nothing currently calls.\n\nThe two differ in behaviour — notably the decomposed `StateDecisionEngine` implements an odd/even\ndouble-tap schedule that the live handler does not. Read `call-ended.ts` when reasoning about\nproduction behaviour.\n====\n\nBecause `call_started` is unregistered, the `dialing` state and the `Call` row are created by\n`initiateCall`, and the attempt counter is incremented when the call ends.\n\n== `call_analyzed` handler\n\nCorrelation comes from Retell metadata: `call.metadata.campaignId` and\n`call.metadata.campaignTargetId`. Without both, the handler logs and returns — an inbound or\nmanually-created Retell call has no campaign context.\n\n=== 1. Technical metrics\n\n`duration_ms`, `disconnection_reason`, `channel_type`, `call_cost.combined_cost`,\n`call_cost.total_duration_unit_price` (stored as `latencyMs`), `start_timestamp`, `end_timestamp`.\n\n=== 2. AI analysis\n\nFrom `call.call_analysis`: `in_voicemail`, `call_screening_detected`, `ai_suspicion_detected`,\n`user_sentiment`, `detected_language`, `call_summary`, `call_successful`.\n\n=== 3. Business extraction\n\nFrom `call_analysis.custom_analysis_data`: `call_disposition` (or `disposition`), `next_action`,\n`termination_reason`, `is_amazon_seller`, `meets_sales_threshold`, `is_decision_maker`,\n`callback_requested`, `proposed_callback_time`, `monthly_revenue`, and the contact name.\n\nThe field vocabulary is defined by `DEFAULT_POST_CALL_ANALYSIS` in\n`lib/retellai/post-call-analysis.ts` — the shared contract between the caller agent, Retell and this\nhandler.\n\n=== 4. State decision\n\nPriority order; the first match wins.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Priority | Rule\n\n| 1 | `stopCalling` → `state = DNC`, `attempts += 999999`\n| 2 | disposition `invalid_destination` → `state = failed`, `attempts += 999999`, `lastError = \"Invalid destination\"`\n| 3 | Recoverable — voicemail, screening, technical failure (`dial_no_answer`, `dial_busy`, `dial_failed`, `inactivity`, `machine_detected`), busy without a callback request, `Hangup_Early`, duration under 10 s, or a disposition in `NEGATIVE_DISPOSITIONS_TO_RECALL`. If `attempts < (maxAttempts ?? 10)` → `state = in_progress`, `nextEarliestAttemptAt = now + waitHoursToRecall`\n| 4 | Default → `state = completed`\n|===\n\nThe full cooling-off window is used for every recoverable outcome so the broadcast loop cannot race\nthe Inngest double tap. If Inngest fails entirely, the cooling-off window is the fallback retry.\n\n=== 5. Database updates\n\n`CampaignTarget`:: `state`, `attempts` (increment), `updatedAt`, `lastAttemptAt`,\n`nextEarliestAttemptAt`, `isAmazonSeller`, `amazonSeller` (raw AI answer, defaulting to\n`\"Undetermined\"`), `meetsMonthlyThreshold`, `stopCalling`, `disposition`, `lastCallRecordingUrl`,\n`nextAction`, `lastError`, `validConversation` (`durationSec >= 10`), `leaseExpiresAt = null`.\n\n`Call`:: updated by `(providerCallId, targetId)` — `status = ended`, durations, cost, latency,\n`channelType`, `disconnectionReason`, `retellDisposition` and `disposition` (only when a disposition\nexists), the detection flags, `callSuccessful`, `isDecisionMaker`, `nextAction`,\n`terminationReason`, `contactName`, `proposedCallbackTime`, `recordingUrl` and `transcript` (only\nwhen present), `userSentiment` (defaulting to `\"Unknown\"`), `summary`, `monthlyRevenue`,\n`meetsMonthlyThreshold`, `startTimestamp`, `endTimestamp`.\n\nConditional spreads are used so a webhook that omits a field does not overwrite a good value with\n`null`.\n\n=== 6. CRM hand-off\n\nEligible when `durationSec >= 10`, not voicemail, not screened, the disposition is in\n`HUBSPOT_WHITE_LISTED_DISPOSITIONS`, and the target is not a test target.\n\nWhen eligible, the handler publishes `Crm.DialerCallEnded` to the CRM SQS queue with the workspace id\nas `messageGroupId`:\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.DialerCallEnded\",\n  \"source\": \"dialer\",\n  \"subscription_id\": \"uuid\",\n  \"call_id\": \"uuid\",\n  \"provider_call_id\": \"retell_call_id\",\n  \"campaign_id\": \"uuid\",\n  \"campaign_name\": \"Campaign Name\",\n  \"target_id\": \"uuid\",\n  \"disposition\": \"Qualified_Lead\",\n  \"duration_sec\": 120\n}\n----\n\nThe direct HubSpot API sync (`hubSync.syncCall`) remains in the source, commented out. CRM writes are\nnow the downstream consumer's responsibility. Using the workspace id as the message group preserves\nper-tenant ordering on a FIFO queue.\n\n=== 7. Emit `call-target/call.ended`\n\nFire-and-forget, with the failure logged:\n\n[source,typescript]\n----\n{\n  callId, providerCallId, campaignId, campaignName, subscriptionId,\n  targetId, targetPhoneNumber, targetPayload,\n  disposition, disconnectionReason, durationSec,\n  inVoicemail, callScreeningDetected, newState,\n}\n----\n\nThis decouples post-call work from the webhook. `callEndedAnalysis` consumes it — see\nxref:workflows:call-ended-analysis.adoc[].\n\n== `call_started` handler (dormant)\n\nIf re-registered, it would validate `campaignId` and `campaignTargetId`, confirm the direction is\noutbound, set the target to `dialing`, increment `attempts`, and set the `Call` to `dialing` with its\nstart timestamp. Re-registering it without also removing the increment in `call-ended.ts` would\ndouble-count attempts.\n"},{"component":"dialer-v2","module":"events","path":"events:resend.adoc","title":"Resend email events","description":"The Resend webhook dispatcher, its handlers and inbound reply correlation.","url":"https://docx.payability.com/dialer-v2/events/resend.html","navOrder":50,"source":"= Resend email events\n:description: The Resend webhook dispatcher, its handlers and inbound reply correlation.\n\nEndpoint:: `POST /api/webhooks/resend`\nDispatcher:: `ResendWebhookDispatcher` in `lib/events/resend-event-handler.ts`\nHandlers:: `lib/events/handlers/resend/`\n\n== Route flow\n\n. Verify the Svix signature when `RESEND_WEBHOOK_SECRET` is set; skipped when unset.\n. Upsert a `WebhookEvent` row, idempotent on the `svix-id` header.\n. Extract `data.email_id` as the `resendEmailId` correlation key.\n. Link the event to its campaign target when `resendEmailId` is not saved yet: first by the\n  `target_id` tag, then by `data.template_id` plus recipient address (`resolveResendWebhookTarget`,\n  skipped for `email.received`). See\n  xref:domain:email-campaigns.adoc#_matching_webhooks_to_targets[Matching webhooks to targets].\n. Dispatch to the handler registered for the event type.\n\n== Handler registry\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Event | Handler | Effect\n\n| `email.sent` | `EmailSentHandler` | Increments `EmailSendingDomain.sentCount` when the target has a `sendingDomainId`, upserts the outbound `Emails` row, and sets target status → `sent` with `sentAt` while it is still `pending` or `requested`\n| `email.delivered` | `EmailDeliveredHandler` | Target status → `sent` with `sentAt`, only while it is still `pending` or `requested`, so a late delivery never downgrades `opened`. Deliberately does *not* increment `sentCount` — `email.sent` already did\n| `email.delivery_delayed` | `EmailDeliveryDelayedHandler` | Treated as sent. Resend fires it after `email.sent`, often more than once, so it only acts when the target is still `pending` or `requested` (the `email.sent` webhook was missed): it then marks the target `sent` and does the `email.sent` side effects. Otherwise it is a no-op, so repeated delays never bump `sentCount` or `sentAt`\n| `email.opened` | `EmailOpenedHandler` | First open only: sets `openedAt` whatever the status, increments `openedCount`; status → `opened` only from `pending`, `requested` or `sent`\n| `email.clicked` | `EmailClickedHandler` | First click only: sets `clickedAt`, increments `clickedCount`\n| `email.bounced` | `EmailBouncedHandler` | Target status → `bounced` unless it is `blocked` or `suppressed`, increments `bouncedCount`; stores bounce type, subtype, message (`lastError`), and SMTP diagnostic. A Permanent bounce also sets `target_emails.isBlocked`, `blockReason`, and `blockedAt` so the address is not sent again\n| `email.complained` | `EmailComplainedHandler` | Target status → `blocked`; upserts `target_emails` with `isBlocked`, `blockReason` (`Spam complaint`), `blockedAt`, `marketingOptOut`, `marketingOptOutAt`\n| `email.suppressed` | `EmailSuppressedHandler` | Resend dropped the send because the address is on its suppression list. Target status → `suppressed` from `pending`, `requested` or `sent`, with `lastError` `Suppressed: <reason>`; `upsertBlockedFromBounce` blocks the address with `blockReason` `resend_suppressed:<reason>`\n| `email.unsubscribed` | `EmailUnsubscribedHandler` | Target status → `unsubscribed` unless it is `blocked` or `suppressed`; upserts `target_emails` with `newsletterOptOut`, `newsletterOptOutAt`; increments `unsubscribedCount`\n| `email.received` | `EmailReceivedHandler` | Inbound reply — see below\n|===\n\nOpens and clicks are deduplicated by checking whether the timestamp is already set, so counters are\nunique-recipient counts rather than raw event counts.\n\nA click sets `clickedAt` but no status: there is no `clicked` value in `EmailTargetStatus`.\n\n== Base class\n\n[source,typescript]\n----\nexport abstract class ResendEventHandler {\n  constructor(protected readonly context: ResendWebhookContext) {}\n  abstract handle(repo: EmailCampaignRepository): Promise<void>\n  protected async bumpSendingDomainStat(repo, sendingDomainId, stat): Promise<void>\n}\n----\n\n`bumpSendingDomainStat` is backed by `EmailCampaignRepository.incrementSendingDomainStat`, so every\nhandler increments counters the same way.\n\n== Inbound replies (`email.received`)\n\nThe `data.email_id` on an inbound event is the *new* message id, not the outbound `resendEmailId`\nstored on the target, so the reply has to be matched heuristically.\n\n. Strip `Re:` / `Fwd:` prefixes from the subject.\n. Match the cleaned subject against `EmailVariation.subject` among targets that share the sender's\n  address, with a fallback when the subject is missing.\n. Upsert an `Activity` row for the reply.\n. Increment `receivedCount` on the sending account.\n. When a target and a `from` address were both resolved, publish to\n  `INCOMING_EMAIL_SQS_QUEUE_URL` through `publishIncomingEmailCategorise`:\n\n[source,json]\n----\n{\n  \"event_type\": \"Email.IncomingCategorise\",\n  \"campaign_id\": \"uuid\",\n  \"contact_email\": \"prospect@example.com\",\n  \"inbound_subject\": \"Re: Quick question\",\n  \"inbound_text\": \"plain text, HTML stripped\"\n}\n----\n\nRequires `AWS_REGION` and credentials. A downstream service classifies the reply and writes back\n`EmailCampaignTarget.category`, `categorisedAt` and `categoryModel`.\n\nSubject matching is why `Reply-To` is derived per sending account\n(`buildReplyToForSendingDomain`) — it narrows the candidate set to targets that used that account.\n\n== Suppression writes\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Handler | Repository call\n\n| `EmailUnsubscribedHandler` | `upsertNewsletterOptOut(subscriptionId, email, contactId?)` — sets `newsletterOptOut` and `newsletterOptOutAt`, creating the `target_emails` row if absent\n| `EmailComplainedHandler` | `upsertBlockedFromComplaint(...)` — sets `isBlocked`, `blockReason` (`Spam complaint`), `blockedAt`, `marketingOptOut`, `marketingOptOutAt`\n| `EmailBouncedHandler` | On a Permanent bounce, `upsertBlockedFromBounce(...)` — sets `isBlocked`, `blockReason`, and `blockedAt`. Does not set opt-out flags. Transient and Undetermined bounces are stored on the campaign target only\n|===\n\nA complaint is treated more harshly than an unsubscribe because it threatens the deliverability of the\nwhole domain, not just one recipient's preference. A Permanent bounce is an invalid address: the\nregistry blocks it, and the campaign target stays `bounced` so bounce stats stay separate from complaints.\n\nDownstream, `emailSend`'s `check-eligibility` step treats `isBlocked` as blocked (status `blocked`,\n`lastError` from `blockReason`) and newsletter or marketing opt-out as unsubscribed. When both are set,\nthe target is blocked. `fetchAndLockPendingTargets` filters on\n`newsletterOptOut = false AND isBlocked = false` at lease time. See\nxref:domain:email-deliverability.adoc#suppression[Suppression].\n\n== CRM publishing\n\nResend handlers call `publishCrmCustomEvent` / `publishCrmNewActivity` *only* when the contact has a\nHubSpot contact id, so a consumer never receives an unresolvable payload. See\nxref:crm-sqs.adoc[].\n"},{"component":"dialer-v2","module":"events","path":"events:nooks.adoc","title":"Nooks events","description":"Ingesting human-dialer call outcomes, rep attribution, AI analysis and closed-lost CRM updates.","url":"https://docx.payability.com/dialer-v2/events/nooks.html","navOrder":51,"source":"= Nooks events\n:description: Ingesting human-dialer call outcomes, rep attribution, AI analysis and closed-lost CRM updates.\n\nhttps://www.nooks.ai[Nooks] is the dialer human reps use. Its call outcomes are ingested through a\ndedicated endpoint and a parallel dispatcher — no campaign or target rows are involved.\n\nEndpoint:: `POST /api/webhooks/nooks`\nDispatcher:: `NooksWebhookDispatcher` in `lib/events/nooks-event-handler.ts`\nPersistence:: `lib/repositories/nooks-repository.ts`\n\nCalls are stored with `channelType = \"nooks_call\"`, `campaignId = null` and `targetId = null` — they\nare rep activity, not campaign activity.\n\n== Route flow\n\n. Optional secret check — `Authorization: Bearer` or `X-Nooks-Signature` against\n  `NOOKS_WEBHOOK_SECRET`.\n. Upsert a `WebhookEvent` row with `source = \"Nooks\"`, idempotent on `eventId`.\n. Dispatch to the registered handler.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Event | Handler | File\n\n| `call.logged` | `CallLoggedHandler` | `lib/events/handlers/nooks/call-logged.ts`\n|===\n\n== Payload\n\n[source,json]\n----\n{\n  \"event\": \"call.logged\",\n  \"eventId\": \"evt_123\",\n  \"occurredAt\": \"2026-01-01T00:00:00Z\",\n  \"callData\": {\n    \"callId\": \"nooks_call_123\",\n    \"status\": \"ended\",\n    \"durationSeconds\": 120,\n    \"disposition\": { \"id\": \"disp_1\", \"name\": \"Connected\" },\n    \"userData\": { \"userId\": \"rep_abc\", \"email\": \"rep@co.com\", \"name\": \"Jane Rep\" },\n    \"prospectData\": { \"prospectId\": \"hs_contact_id\", \"phoneNumber\": \"+15555550100\", \"name\": \"John Prospect\" },\n    \"recordingUrl\": \"https://…\",\n    \"transcriptUrl\": \"https://…\"\n  }\n}\n----\n\n== Rep attribution\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Nooks field | Matched against | Priority\n\n| `userData.userId` | `SalesRep.nooksUserId` | 1st — workspace-scoped, then global\n| `userData.email` | `SalesRep.email` | 2nd — case-insensitive\n|===\n\n`SalesRep.nooksUserId` must be populated for the primary match; email is a fallback that breaks when a\nrep uses a different address in Nooks.\n\n== Post-processing\n\nAfter `NooksRepository.upsertCallFromCallLogged`, two disposition-gated flows run in parallel.\n\n[source]\n----\ncall.logged received\n        │\n        ▼\nNooksRepository.upsertCallFromCallLogged()\n        │\n        ├── disposition NOT in NOOKS_ANALYSIS_SKIP_DISPOSITIONS\n        │        └── emit nooks/call.logged  →  nooksCallAnalysis\n        │\n        └── disposition IN NOOKS_CLOSED_LOST_DISPOSITIONS\n                 └── resolveHubspotContactId() → publishCrmWorkflowEvent(Crm.DealUpdate)\n----\n\n=== AI analysis\n\nSkipped for `NOOKS_ANALYSIS_SKIP_DISPOSITIONS`: `\"No Answer\"`, `\"Voicemail\"`, `\"Busy\"` — the call never\nconnected, so there is nothing to grade.\n\nOtherwise `nooksCallAnalysis` runs `CallAnalysisService.runAndSave(callId, null)`, persisting a\n`CallAiAgentAnalysisResult` with `campaignId = null`. There is no campaign, so the workspace's default\nanalysis agent is used, and because there is no caller agent either, the system prompt is\n`DEFAULT_HUMAN_CALL_ANALYSIS_SYSTEM_PROMPT` — which reframes the task as sales coaching: does the\nlogged disposition match the conversation, how were objections handled, are there compliance or tone\nissues.\n\n=== Closed-lost CRM update\n\nTriggered for `NOOKS_CLOSED_LOST_DISPOSITIONS`: `\"Not Interested\"`, `\"Do Not Call\"`,\n`\"Marketplace not supported\"`, `\"No longer selling/shut down\"`.\n\n`NooksRepository.resolveHubspotContactId` resolves the contact:\n\n. `callData.prospectData.prospectId`, used directly when Nooks provides it.\n. Phone registry fallback — `TargetPhoneNumbers` → `Contact.hubspotContactId`.\n\nThen publishes to `CRM_QUEUE_URL`:\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.DealUpdate\",\n  \"hubspot_contact_id\": \"<resolved>\",\n  \"deal_stage\": \"closedlost\",\n  \"note\": \"Outreach: Closed deal because of a disposition from Nooks: <disposition>. Call ID: <callId>\"\n}\n----\n\nIf no HubSpot contact id can be resolved, a warning is logged and nothing is sent — an unresolvable\nCRM payload is worse than none.\n\n== Environment\n\n[source,bash]\n----\nNOOKS_WEBHOOK_SECRET=   # optional; enables Bearer / X-Nooks-Signature verification\nCRM_QUEUE_URL=          # required for Crm.DealUpdate\nAWS_REGION=             # required for SQS\n----\n\n== Adding an event type\n\n. Create `lib/events/handlers/nooks/<event-name>.ts` extending `NooksEventHandler`.\n. Export it from `lib/events/handlers/nooks/index.ts`.\n. Call `this.register(\"<event.type>\", HandlerClass)` in the `NooksWebhookDispatcher` constructor.\n\n== Five9\n\n`POST /api/webhooks/five9` stores its payload and returns. It writes with `source = \"Nooks\"` and\n`eventType = \"five_9\"`, generating a random `eventId` and `providerCallId`, so its rows are not\nidempotent on replay and share the `Nooks` source with genuine Nooks events. Filter on `eventType` to\nseparate them. No dispatch occurs.\n"},{"component":"dialer-v2","module":"events","path":"events:telnyx.adoc","title":"Telnyx AI assistant events","description":"Store-only ingestion of Telnyx AI assistant call webhooks.","url":"https://docx.payability.com/dialer-v2/events/telnyx.html","navOrder":52,"source":"= Telnyx AI assistant events\n:description: Store-only ingestion of Telnyx AI assistant call webhooks.\n\nTelnyx posts AI assistant call events to a dedicated endpoint, as JSON or form-encoded. The route\nverifies the signature, decodes the body, stores it, and dispatches through an empty registry. No call, campaign, or CRM work\nhappens yet.\n\nEndpoint:: `POST /api/webhooks/telnyx`\nDispatcher:: `TelnyxWebhookDispatcher` in `lib/events/telnyx-event-handler.ts`\nPersistence:: `WebhookEventRepository.record()` in `lib/repositories/webhook-event-repository.ts`\n\n== Route flow\n\n. Require `TELNYX_PUBLIC_KEY`. When it is unset, return `503`.\n. Verify the Ed25519 signature in `telnyx-signature-ed25519` over `{telnyx-timestamp}|{rawBody}`.\n  Reject a missing header, a bad signature, or a timestamp more than five minutes off with `401`.\n  Only the reason (`missing_headers`, `stale_timestamp`, `invalid_signature`) is logged.\n. Decode the body with `decodeTelnyxBody` (JSON or form-encoded, see below). An undecodable body\n  returns `400 { error: \"Invalid body\" }`.\n. Upsert a `WebhookEvent` row with `source = \"Telnyx\"`, idempotent on the event id.\n. Dispatch through `TelnyxWebhookDispatcher`. No handlers are registered, so every event type is stored\n  and acknowledged with `200 { received: true, event_type }`.\n\nA database failure returns `500` so Telnyx retries. The unique key on `(source, eventId)` makes that\nretry safe.\n\nRaw bodies are never logged. They contain phone numbers, conversation transcripts (`Messages`), and\npre-signed recording URLs. The route logs only the event type, format, event id, and `providerCallId`.\n\n== Body formats\n\nTelnyx sends two formats to the same endpoint. `decodeTelnyxBody` picks one from `Content-Type`.\nWithout a usable header, it treats a body starting with `{` (or an empty body) as JSON and anything\nelse as form-encoded.\n\nJSON:: AI insight results (`conversation_insight_result`) and other JSON events.\nForm-encoded (`application/x-www-form-urlencoded`):: Call-progress, call-cost, conversation, and\nrecording callbacks.\n\nForm decoding:\n\n* A bracketed key such as `CallCost[v3:abc]=0.2430` becomes `{ \"CallCost\": { \"v3:abc\": \"0.2430\" } }`.\n  `BilledDurationSeconds[...]` is decoded the same way.\n* A value that starts with `{` or `[` and parses as JSON, such as `Messages` or `CallQualityStats`, is\n  stored parsed. Every other value stays a string.\n\nThe decoded object is what is stored in `rawPayload`.\n\n== Event types\n\nJSON events use `data.event_type`, then `event_type`, `type`, and `event`.\n\nForm callbacks carry no event type, so the route derives one. These names are ours, not Telnyx's;\nregister handlers against them.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Fields present | Derived `eventType` | Examples\n\n| `RecordingStatus`\n| `recording.<RecordingStatus>`\n| `recording.completed`\n\n| `CallbackSource` and `CallStatus`\n| `<CallbackSource>.<CallStatus>`\n| `call-progress-events.in-progress`, `call-progress-events.completed`, `call-cost-events.completed`\n\n| `CallStatus` only\n| `<CallStatus>`\n| `conversation_created`, `conversation_messages_added`, `conversation_ended`\n\n| none of the above\n| `unknown`\n|\n|===\n\n== Correlation and idempotency\n\n`providerCallId`:\n\n* JSON: `payload.conversation_id`, then `payload.call_control_id`, then\n  `payload.metadata.call_control_id`. `data.payload` is preferred over a top-level `payload`.\n* Form: `ConversationId`, then `CallControlId`, then `CallSid`.\n\n`eventId` is `data.id` or `id` when Telnyx sends one. Otherwise it is `sha256:<hex of the raw body>`,\nand `eventIdGenerated` is true. Form callbacks never carry an id, so they always use the hash. A Telnyx\nretry resends the same body, so the hash dedupes it. Two different callbacks, for example two\n`conversation_messages_added` deliveries, have different bodies and never collide.\n\n== Examples\n\nA JSON event wrapped in `data`:\n\n[source,json]\n----\n{\n  \"data\": {\n    \"record_type\": \"event\",\n    \"event_type\": \"call.conversation.created\",\n    \"id\": \"eb8775a6-634f-48b0-b177-d5465a8a8e9f\",\n    \"payload\": {\n      \"call_control_id\": \"v3:example\",\n      \"conversation_id\": \"0424805b-adc1-4ff8-9f95-e1de6883ecbe\"\n    }\n  }\n}\n----\n\nA form-encoded call-cost callback, shown decoded. The derived event type is\n`call-cost-events.completed` and `providerCallId` is the `CallSid`:\n\n[source,json]\n----\n{\n  \"CallbackSource\": \"call-cost-events\",\n  \"CallStatus\": \"completed\",\n  \"CallSid\": \"v3:example\",\n  \"CallSessionId\": \"9760cbf0-0000-0000-0000-000000000000\",\n  \"CallCost\": { \"v3:example\": \"0.2430\" },\n  \"BilledDurationSeconds\": { \"v3:example\": \"60\" },\n  \"Direction\": \"outbound-api\"\n}\n----\n\n== Handlers\n\nNone. Register a handler on `TelnyxWebhookDispatcher` once stored rows show which event types matter.\nSee xref:extending.adoc[].\n"},{"component":"dialer-v2","module":"events","path":"events:elevenlabs.adoc","title":"ElevenLabs post-call events","description":"Store-only ingestion of ElevenLabs Agents post-call webhooks.","url":"https://docx.payability.com/dialer-v2/events/elevenlabs.html","navOrder":53,"source":"= ElevenLabs post-call events\n:description: Store-only ingestion of ElevenLabs Agents post-call webhooks.\n\nElevenLabs posts a webhook after each agent conversation ends. The route verifies the signature, decodes\nthe JSON body, and stores it. There is no dispatcher and no handler; nothing else happens.\n\nEndpoint:: `POST /api/webhooks/elevenlabs`\nHelpers:: `lib/utils/elevenlabs/webhook.ts`\nPersistence:: `WebhookEventRepository.record()` in `lib/repositories/webhook-event-repository.ts`\n\nConfigure the URL as the ElevenLabs workspace post-call webhook (ElevenAgents settings).\n\n== Route flow\n\n. Require `ELEVENLABS_WEBHOOK_SECRET`. When it is unset, return `503`.\n. Verify the `ElevenLabs-Signature` header, `t=<unix seconds>,v0=<hex>`, where `v0` is HMAC-SHA256 of\n  `{t}.{rawBody}` keyed with the secret. Reject with `401` when the header is missing or malformed, the\n  timestamp is more than 30 minutes from now, or no `v0` matches. Only the reason (`missing_headers`,\n  `malformed_header`, `stale_timestamp`, `invalid_signature`) is logged.\n. Parse the body as JSON. Invalid JSON or a non-object returns `400 { error: \"Invalid body\" }`.\n. Upsert a `WebhookEvent` row with `source = \"ElevenLabs\"`, idempotent on the event id.\n. Return `200 { received: true, event_type }`.\n\nA database failure returns `500`, which ElevenLabs retries when retries are enabled on the webhook.\nElevenLabs never retries a `4xx`.\n\nRaw bodies are never logged. Transcription events contain the full conversation transcript, dynamic\nvariables, and phone details. The route logs only the event type, event id, and conversation id.\n\n== Event types\n\n`eventType` is the top-level `type`, or `unknown` when it is missing.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `type` | `data` contents\n\n| `post_call_transcription`\n| `agent_id`, `conversation_id`, `status`, `transcript`, `metadata` (timing, cost, phone details),\n`analysis` (summary, evaluation and data collection results), `conversation_initiation_client_data`, and\nthe `has_audio` flags\n\n| `post_call_audio`\n| `agent_id`, `conversation_id`, and `full_audio`, a base64 MP3 of the whole call\n\n| `call_initiation_failure`\n| `agent_id`, `conversation_id`, `failure_reason` (`busy`, `no-answer`, `unknown`), and `metadata`\n`{ type: \"sip\" | \"twilio\", body }` with provider details\n|===\n\n== Audio\n\n`post_call_audio` is stored without its audio. `data.full_audio` is replaced by\n`full_audio_omitted: true` and `full_audio_base64_length`, so the row records that audio was sent and how\nlarge it was.\n\nLeave \"Send audio data\" off in ElevenLabs unless audio is needed. Vercel rejects request bodies over about\n4.5 MB with `413` before the route runs, so audio for longer calls never arrives. ElevenLabs does not\nretry `4xx`, and repeated failures can disable the webhook automatically.\n\n== Correlation and idempotency\n\n`providerCallId` is `data.conversation_id`.\n\nElevenLabs sends no event id. A conversation produces at most one event of each type, so `eventId` is\n`{type}:{conversation_id}`, for example `post_call_transcription:abc`. A retry of the same event dedupes on\nit. When `type` or `conversation_id` is missing, `eventId` is `sha256:<hex of the raw body>` and\n`eventIdGenerated` is true.\n\n== Example\n\n[source,json]\n----\n{\n  \"type\": \"call_initiation_failure\",\n  \"event_timestamp\": 1759931652,\n  \"data\": {\n    \"agent_id\": \"xyz\",\n    \"conversation_id\": \"abc\",\n    \"failure_reason\": \"busy\",\n    \"metadata\": {\n      \"type\": \"sip\",\n      \"body\": { \"sip_status_code\": 486, \"error_reason\": \"Busy here\", \"call_sid\": \"d8e7f6a5-...\" }\n    }\n  }\n}\n----\n\nStored with `eventId = \"call_initiation_failure:abc\"` and `providerCallId = \"abc\"`.\n"},{"component":"dialer-v2","module":"events","path":"events:crm-sqs.adoc","title":"CRM queue and HubSpot events","description":"Outbound CRM messages over SQS, inbound HubSpot webhooks, and the conditional-publish rule.","url":"https://docx.payability.com/dialer-v2/events/crm-sqs.html","navOrder":54,"source":"= CRM queue and HubSpot events\n:description: Outbound CRM messages over SQS, inbound HubSpot webhooks, and the conditional-publish rule.\n\nCRM writes are not made inline. The application publishes messages to an SQS queue and a downstream\nPayability service applies them to HubSpot. Inbound HubSpot notifications travel the same path in\nreverse.\n\n== Publishers\n\n`lib/plugins/sqs-client.ts`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Function | Purpose\n\n| `publishCrmWorkflowEvent(body, opts?)` | Generic publish to `CRM_QUEUE_URL`; `opts.messageGroupId` sets FIFO ordering\n| `publishCrmNewActivity(...)` | `Crm.NewActivity` — an outbound touch was made\n| `publishCrmCustomEvent(...)` | `Crm.CustomEvent` — a custom timeline event\n| `publishCrmCreateTask(...)` | `Crm.TaskCreate` — create a HubSpot task\n| `publishIncomingEmailCategorise(...)` | `Email.IncomingCategorise` to `INCOMING_EMAIL_SQS_QUEUE_URL`\n|===\n\n== Event types\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| `event_type` | Meaning | Producer\n\n| `Crm.DialerCallEnded` | An eligible AI call finished | Retell `call_analyzed` handler\n| `Crm.DealUpdate` | Move a deal stage, e.g. `closedlost` | Nooks `call.logged` handler\n| `Crm.NewActivity` | An outbound touch was made | `emailSend`, Resend handlers\n| `Crm.CustomEvent` | Custom timeline event | Resend handlers\n| `Crm.TaskCreate` | Create a HubSpot task | Sequence `crm_task` step\n| `Crm.HubSpotWebhook` | A normalised inbound HubSpot notification | `POST /api/webhooks/hubspot`\n| `Contact.Update` | Update contact properties | Sequence `crm_event` step\n| `Contact.Unsubscribe` | Record an opt-out | `/api/unsubscribe`\n| `Email.IncomingCategorise` | Classify an inbound reply | Resend `email.received` handler\n|===\n\n`CrmWorkflowEventTypeEnum` in `lib/enums.ts` covers `Crm.DialerCallEnded` and `Crm.DealUpdate`;\n`CRM_EVENT_TYPES` in `lib/sequences/crm-event.ts` covers the types a sequence step may select\n(`Crm.TaskCreate`, `Contact.Update`).\n\n== The conditional-publish rule\n\nHandlers publish CRM messages *only* when a HubSpot contact id is available. This is consistent across\nproducers:\n\n* Resend handlers call `publishCrmCustomEvent` / `publishCrmNewActivity` only when the contact has a\n  HubSpot id.\n* The sequence `crm_task` step skips with `no_hubspot_contact` when the contact is not in HubSpot.\n* The Nooks closed-lost flow logs a warning and sends nothing when it cannot resolve an id.\n\nThe reason is that the consumer has no way to resolve an unidentified contact, so an id-less message is\nguaranteed to fail downstream. Failing early and visibly is better than filling the queue with\nundeliverable work.\n\n== FIFO ordering\n\n`messageGroupId` is set to the workspace id with dashes stripped (`Crm.DialerCallEnded`), or to the\nHubSpot contact id falling back to the workspace id (sequence `crm_event` steps). Messages within a\ngroup are processed in order, so two updates to the same contact cannot be applied out of sequence.\n\n== Inbound HubSpot webhooks\n\n`POST /api/webhooks/hubspot`:\n\n. Read the raw body.\n. Validate the HubSpot HMAC or legacy signature using `HUBSPOT_WEBHOOK_CLIENT_SECRET`\n  (`lib/utils/hubspot/webhook-signature.ts`). Unlike the Resend route, this one *rejects* when the\n  secret is unset.\n. Normalise the payload (`lib/utils/hubspot/webhook-normalize.ts`).\n. Publish as `Crm.HubSpotWebhook` through `publishCrmWorkflowEvent`.\n\nThe application does not act on HubSpot notifications directly; it forwards them so a single downstream\nservice owns all CRM reconciliation.\n\n== HubSpot client\n\n`lib/plugins/hubspot/pyb.ts` wraps `@payability/pyb-hubspot` and `@hubspot/api-client` for the\nbest-effort engagement logs (sequence `email` step and the `send_email` MCP tool).\n`HubSpotListsClient` in the same folder extends `HubSpotClient` for the daily-list pre-list\nfind-or-create. Existing imports from `lib/plugins/pyb-hubspot` still work.\n\nRelated constants in `lib/constants.ts`: `HUBSPOT_PORTAL_ID`, `HUBSPOT_AI_OWNER_ID`,\n`HUBSPOT_TASK_OWNER_ID`, `HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID`,\n`HUBSPOT_LIFECYCLE_STAGE_PROPERTY`, `CRM_UNSUBSCRIBE_LEGAL_BASIS`,\n`CRM_UNSUBSCRIBE_LEGAL_BASIS_EXPLANATION`.\n\n== Environment\n\n[source,bash]\n----\nCRM_QUEUE_URL=\nINCOMING_EMAIL_SQS_QUEUE_URL=\nEMAIL_VARIATION_QUEUE_URL=\nAWS_REGION=\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nHUBSPOT_WEBHOOK_CLIENT_SECRET=\nHUBSPOT_PRIVATE_APP_TOKEN=\n----\n"},{"component":"dialer-v2","module":"events","path":"events:extending.adoc","title":"Adding a handler","description":"How to register a new webhook event type, and what to check before you do.","url":"https://docx.payability.com/dialer-v2/events/extending.html","navOrder":55,"source":"= Adding a handler\n:description: How to register a new webhook event type, and what to check before you do.\n\nEach dispatcher uses the same registry pattern, so adding an event type is a three-line change\nplus a handler class.\n\n== Retell\n\n. Create `lib/events/handlers/<event-name>.ts` extending `RetellCallEventHandler`:\n\n[source,typescript]\n----\nimport { RetellCallEventHandler } from \".\"\n\nexport class MyEventHandler extends RetellCallEventHandler {\n  async handle() {\n    const call = (this.context.payload?.call ?? {}) as Record<string, any>\n    const campaignId = call?.metadata?.campaignId\n    const targetId = call?.metadata?.campaignTargetId\n    if (!campaignId || !targetId) return   // no campaign context — nothing to do\n    // …\n  }\n}\n----\n\n. Export it from `lib/events/handlers/index.ts`.\n. Register it in the `WebhookEventDispatcher` constructor:\n\n[source,typescript]\n----\nthis.register(\"my_event\", MyEventHandler)\n----\n\n. Add the event type in the Retell dashboard's webhook configuration.\n\n== Resend\n\n. Create `lib/events/handlers/resend/email-<type>.ts` extending `ResendEventHandler`, implementing\n  `handle(repo: EmailCampaignRepository)`.\n. Export it from `lib/events/handlers/resend/index.ts`.\n. Register it in the `ResendWebhookDispatcher` constructor:\n  `this.register(\"email.<type>\", EmailTypeHandler)`.\n. Subscribe to the event in the Resend dashboard.\n\nUse `this.bumpSendingDomainStat(repo, sendingDomainId, field)` for counters rather than writing them\ndirectly, so every handler increments the same way.\n\n== Nooks\n\n. Create `lib/events/handlers/nooks/<event-name>.ts` extending `NooksEventHandler`.\n. Export it from `lib/events/handlers/nooks/index.ts`.\n. Register it in the `NooksWebhookDispatcher` constructor.\n\n== Telnyx\n\n`TelnyxWebhookDispatcher` has no handlers yet. `POST /api/webhooks/telnyx` only stores the raw event.\nWhen a stored payload shows an event type worth handling:\n\n. Create `lib/events/handlers/telnyx/<event-name>.ts` extending `TelnyxEventHandler`.\n. Register it in the `TelnyxWebhookDispatcher` constructor:\n  `this.register(\"<event_type>\", HandlerClass)`.\n\n== Rules for handler code\n\nBe idempotent::\nA provider may deliver the same event twice, and an operator may replay a stored `WebhookEvent`. Guard\nwith a unique constraint (`providerCallId`, `(subscriptionId, externalId)`) or a status check, not with\na hope that it will not happen.\n\nValidate before acting::\nMissing correlation metadata means the event is not yours — log and return rather than guessing.\n\nDo not throw for expected conditions::\nAn unmatched target or an absent HubSpot id is a normal outcome. Log it and return; reserve throwing\nfor genuine bugs.\n\nDelegate through a repository::\nHandlers may read a lookup row directly, but persistence belongs in `lib/repositories`. The one\nsanctioned exception is the `WebhookEvent` upsert in the route, which is part of accepting the request.\n\nEmit, don't block::\nAnything slow — AI analysis, retries, CRM sync — becomes an Inngest event. Keep `handle()` to the state\nchange that must be visible immediately.\n\nUse conditional spreads on updates::\n`...(call.recording_url ? { recordingUrl: call.recording_url } : {})`. A webhook that omits a field\nshould not overwrite a good value with `null`.\n\n== Testing a handler\n\nThere is no test runner configured. In practice:\n\n. Find a representative payload in the `WebhookEvent` table.\n. `curl` it at the endpoint locally.\n. Verify the resulting `Call`, `CampaignTarget` or `EmailCampaignTarget` rows.\n. Check the Inngest dashboard for the emitted event and its function run.\n\nThe decomposed `call-ended/` handler shows the shape to aim for if you want a handler that *can* be\nunit-tested: dependencies injected through `dependencies.ts`, database access behind an adapter\ninterface, and the state machine isolated in a pure `StateDecisionEngine`.\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:index.adoc","title":"Workflows","description":"The durable Inngest functions, their triggers, guards and configuration.","url":"https://docx.payability.com/dialer-v2/workflows/index.html","navOrder":56,"source":"= Workflows\n:description: The durable Inngest functions, their triggers, guards and configuration.\n\nDurable work lives in `lib/workflows/functions/` and is served together from\n`app/api/inngest/route.ts`. Each side effect is its own `step.run`, so it retries independently and is\nskipped on replay; `step.sleep` provides real delays without holding concurrency.\n\n== Registered functions\n\n[cols=\"1,1,1,1,2\",options=\"header\"]\n|===\n| Function | Trigger event | Retries | Concurrency | Purpose\n\n| `callTarget` | `call-target/call.requested` | 0 | 5 | Window check → pre-validate → dial\n| `callEndedAnalysis` | `call-target/call.ended` | 0 | 20 | Double tap, contact update, AI analysis\n| `callCycleBudgetCron` | cron `0 8,20 * * *` ET | 2 | 1 | Sum phone-call `callCost` for the Eastern billing cycle, the calendar month (1st 00:00 through the last day 23:59 ET). Pause that workspace's running call campaigns when the total exceeds `Subscription.callCycleBudgetUsd`.\n| `nooksCallAnalysis` | `nooks/call.logged` | 0 | 10 | AI analysis of a human rep's call\n| `emailSend` | `email-campaign/send.requested` | 2 | 20 | Send one campaign email\n| `emailCampaignDuplicate` | `email-campaign/duplicate.requested` | 2 | 5 | Chunked recipient copy\n| `emailCampaignImportSegment` | `email-campaign/import-segment.requested` | 2 | 5 | Chunked segment member copy\n| `segmentRefresh` | `segment/refresh.requested` | 2 | 1 per `strategyId` | Fill staging from Outreach filters or BigQuery SQL, insert newcomers, soft-close this strategy's dropouts when the scan is complete\n| `segmentRefreshCron` | cron `0 6,18 * * *` ET | 2 | — | List active re-engagement segments and enqueue one refresh event per Outreach or BigQuery strategy\n| `reengagementDailyList` | `segment/daily-list.requested` | 2 | 1 per `strategyId` | Pick today's list from open members (score desc, volume cap, stale skip), persist HubSpot list ids, add assigned contacts to those lists\n| `reengagementDailyListCron` | cron `0 7 * * *` ET; `segment/daily-list-cron.requested` | 2 | 1 per `subscriptionId` or `all` | Find-or-create today's HubSpot daily segments for active sales reps, attach tenant-scoped list ids to pick events, then enqueue one daily-list event per active Outreach or BigQuery re-engagement strategy\n| `reengagementNeverCalledDailyList` | cron `30 7 * * *` ET; `segment/never-called-daily-list.requested` | 2 | 1 | One shared HubSpot list, `Outreach-Never-Called-{YYYY-MM-DD}`, of up to 1,000 people already on a re-engagement list who have never had a human-rep call. Longer waits are more likely to be picked.\n| `sequenceExecute` | `sequence/execute` | 1 | 50 | Run a contact's whole sequence\n| `hubspotContactImport` | cron `0 6,10,14,18,22 * * *` ET; `contacts/hubspot-import.requested` | 2 | 1 | Pull up to 1000 HubSpot contacts missing from the Outreach BQ mirror and upsert them into `HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID`\n| `hubspotCallImport` | cron `0 8,20 * * *` ET; `calls/hubspot-import.requested` | 2 | 1 | Pull up to 1000 HubSpot CALL engagements from `v_calls_latest` after Configs `hubspot_call_sync_cursor` and upsert them into the same workspace\n| `payabilitySupplierImport` | cron `0 6,10,14,18,22 * * *` ET; `suppliers/payability-import.requested` | 2 | 1 | Incremental upsert from `v_supplier_summary_extended` after `supplier_sync_cursor`, then re-pull the oldest `lastSyncedAt` locals by key so status changes that do not bump BQ `updated` still write\n| `webhookEventPurgeCron` | cron `0 3 * * *` ET | 2 | 1 | Delete `WebhookEvent` rows with `receivedAt` strictly older than seven days, in batches of 1,000, up to 50,000 rows per run. A remaining backlog continues the next day.\n| `phoneNumberDailyResetCron` | cron `0 0 * * *` UTC | 2 | 1 | Zero `PhoneNumbers.dailyCallCount` on every live number\n| `sendingDomainDailyResetCron` | cron `0 0 * * *` UTC | 2 | 1 | Zero `EmailSendingDomain.dailySentCount` and clear `dailySentDate`. A backstop: the daily cap already ignores counters from an earlier UTC day\n| `phoneNumberOptimizerCron` | cron `15 11 * * 1` UTC | 2 | 1 | Release numbers unused for 3+ days with fewer than 10 lifetime calls. `find-unused` lists candidates, then each release is its own `release-<id>` step, so a retry never repeats a finished release. A failed release is logged and counted, and does not stop the rest\n| `purchasePhoneNumber` | `phone-number/purchase.requested` | 0 | 1 | Buy and register a number (body currently disabled)\n| `processRequest` | — | — | — | Scaffolding / test function\n| `testContextCollection` | — | — | — | Scaffolding / test function\n|===\n\nA function that is not in the `functions` array of `app/api/inngest/route.ts` will never run.\n\n`batchCallAnalysisWorkflow` (`lib/workflows/batch-call-analysis-workflow.ts`) is *not* an Inngest\nfunction — it uses the `'use workflow'` / `'use step'` directive runtime. See\nxref:utility.adoc#batch-call-analysis[Batch call analysis].\n\n== Client\n\n`lib/plugins/inngest-client.ts` creates the client with id `dialer-v2-app` and installs\n`@inngest/middleware-sentry`, so a failing step is reported with its workflow context. Sentry is\ninitialised in the same module.\n\n== Guard mechanisms\n\nThe functions use four distinct Inngest guards. Knowing which one applies is often the answer to \"why\ndid this not run twice\".\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Mechanism | Where | Effect\n\n| `retries: 0`\n| `callTarget`, `callEndedAnalysis`, `nooksCallAnalysis`, `purchasePhoneNumber`\n| A discrete business action must not be repeated automatically. Retrying a dial would call the prospect twice\n\n| `rateLimit`\n| `callTarget` (2 per `targetPhoneNumber` per 24 h), `callEndedAnalysis` (1 per `callId` per 24 h), `nooksCallAnalysis` (1 per `callId` per 24 h)\n| Excess events are *dropped*, not queued. This is the hard ceiling behind all the softer campaign limits\n\n| `idempotency`\n| `purchasePhoneNumber` — `subscriptionId + state + areaCode`\n| Only one purchase in flight per state and area code per workspace\n\n| `cancelOn`\n| `sequenceExecute` — `sequence/cancel` matched on `data.contactSequenceId`\n| Tears the run down immediately, including an in-progress `step.sleep`\n|===\n\n== Retry semantics\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `retries` | Meaning here\n\n| `0` | The action has an external side effect that must not repeat. Failures are returned as data (`skipped`, `skipReason`) rather than thrown, so an ineligible target does not look like an error\n| `1` | `sequenceExecute` — one retry is worth it because a whole journey is at stake, and each step is individually replay-safe\n| `2` | `emailSend`, `emailCampaignDuplicate`, `segmentRefresh`, `segmentRefreshCron`, `hubspotContactImport`, `hubspotCallImport`, `payabilitySupplierImport`, `webhookEventPurgeCron`, `phoneNumberDailyResetCron`, `sendingDomainDailyResetCron`, `phoneNumberOptimizerCron` — protected by their own idempotency guards, so a retry is safe\n|===\n\n== Diagram\n\n[mermaid]\n....\nflowchart TD\n  Cron1[\"pg_cron\"] --> B1[\"POST /api/campaigns/{id}/broadcast\"]\n  B1 -->|\"call-target/call.requested\"| CT[\"callTarget\"]\n  CT -->|Retell API| Retell[\"Retell AI\"]\n  Retell -->|\"webhook call_analyzed\"| WH[\"/api/webhooks/event-logger\"]\n  WH -->|\"call-target/call.ended\"| CEA[\"callEndedAnalysis\"]\n  CEA -->|\"double tap: sleep 1m\"| CT\n  CEA --> Analysis[\"CallAnalysisService.runAndSave\"]\n\n  Cron2[\"pg_cron\"] --> B2[\"POST /api/email-campaigns/{id}/broadcast\"]\n  B2 -->|\"email-campaign/send.requested\"| ES[\"emailSend\"]\n  ES -->|Resend API| Resend[\"Resend\"]\n  Resend -->|webhooks| WH2[\"/api/webhooks/resend\"]\n\n  NooksWH[\"/api/webhooks/nooks\"] -->|\"nooks/call.logged\"| NCA[\"nooksCallAnalysis\"]\n  Enroll[\"POST /api/contact-sequences/{id}/start\"] -->|\"sequence/execute\"| SE[\"sequenceExecute\"]\n  Stop[\"POST /api/contact-sequences/{id}/stop\"] -->|\"sequence/cancel\"| SE\n  Dup[\"POST /api/email-campaigns/{id}/duplicate\"] -->|\"email-campaign/duplicate.requested\"| ECD[\"emailCampaignDuplicate\"]\n  Seg[\"POST .../targets/from-segment\"] -->|\"email-campaign/import-segment.requested\"| ECS[\"emailCampaignImportSegment\"]\n  Refresh[\"POST /api/segments/{id}/refresh\"] -->|\"segment/refresh.requested\"| SR[\"segmentRefresh\"]\n  CronSeg[\"segmentRefreshCron 06:00/18:00 ET\"] -->|\"segment/refresh.requested\"| SR\n  Daily[\"POST /api/segments/{id}/daily-list\"] -->|\"segment/daily-list.requested\"| DL[\"reengagementDailyList\"]\n  DailyWs[\"POST /api/reengagement/daily-list\"] -->|\"segment/daily-list-cron.requested\"| CronDaily\n  CronDaily[\"reengagementDailyListCron\"] -->|\"segment/daily-list.requested\"| DL\n  CronNever[\"reengagementNeverCalledDailyList 07:30 ET\"] --> HsNever[\"Outreach-Never-Called list\"]\n  CronHs[\"hubspotContactImport 06:00/10:00/14:00/18:00/22:00 ET\"] --> BQ[\"hubspot4.v_contacts_latest\"]\n  CronHs --> Contacts[\"Contact + TargetPhoneNumbers + TargetEmails\"]\n  CronHsCall[\"hubspotCallImport 08:00/20:00 ET\"] --> BQCalls[\"hubspot4.v_calls_latest\"]\n  CronHsCall --> Calls[\"Call\"]\n  CronSup[\"payabilitySupplierImport 06:00/10:00/14:00/18:00/22:00 ET\"] --> BQSup[\"PayabilitySheets.v_supplier_summary_extended\"]\n  CronSup --> Suppliers[\"Supplier\"]\n....\n\n== Conventions\n\n* Name steps descriptively and stably — step ids are the replay key. `sequenceExecute` embeds the step\n  index, retry count, channel and a slugified name so the dashboard is readable and ids survive replays.\n* Keep each `step.run` to one side effect. A step that does two things cannot retry half of them.\n* Import heavy dependencies inside the step (`await import(...)`) where the function is otherwise cheap\n  to load.\n* Return structured data (`{ skipped, reason, skipReason }`) rather than throwing for expected outcomes.\n* Decrement the Redis in-flight counter on *every* exit path, not just the happy one.\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:call-target.adoc","title":"callTarget","description":"The dial pipeline — window check, pre-validation, call initiation and counter hygiene.","url":"https://docx.payability.com/dialer-v2/workflows/call-target.html","navOrder":57,"source":"= `callTarget`\n:description: The dial pipeline — window check, pre-validation, call initiation and counter hygiene.\n\nFile:: `lib/workflows/functions/call-target.ts`\nEvent:: `call-target/call.requested`\nConfig:: `retries: 0`, `concurrency: { limit: 5 }`,\n`rateLimit: { key: \"event.data.targetPhoneNumber\", limit: 2, period: \"24h\" }`\n\n== Event payload\n\n[source,typescript]\n----\ninterface CallTargetEventData {\n  campaignId: string\n  campaignName: string\n  targetId: string\n  targetPhoneNumber: string\n  targetPayload?: Record<string, unknown>\n  subscriptionId: string\n}\n----\n\nEmitted by `PhoneNumberCallingService.processBroadcast` and re-emitted by `callEndedAnalysis` on a\ndouble tap.\n\n== Why these settings\n\n`retries: 0`::\nA dial is a discrete business action. An automatic retry would call the prospect a second time, so\nfailures are returned as data rather than thrown.\n\n`rateLimit` on `targetPhoneNumber`::\nAn absolute ceiling of two dials per number per 24 hours, independent of campaign configuration,\nbroadcaster behaviour or double taps. Excess events are *dropped*, not queued. This is the last line of\ndefence against harassing a prospect.\n\n`concurrency: { limit: 5 }`::\nCaps simultaneous Retell call creation regardless of how many events are enqueued.\n\n== Steps\n\n=== `check-call-window`\n\n`PhoneNumberCallingService.checkCallWindow(campaignId, targetId)`.\n\nReads the campaign's `windowStart`, `windowEnd` and `allowWeekends`, and the target's `tz` (derived from\nthe area code, defaulting to `America/New_York`), converts the current UTC instant into that zone with\n`Intl`, and returns false when the local time is outside `[windowStart, windowEnd)` or it is a\ndisallowed weekend.\n\nNot in window → return `{ skipped: true, reason, skipReason: outside_call_window }`. The lease is *not*\ncleared, so the target is re-picked naturally once it expires.\n\n=== `pre-validate`\n\n`preValidateTargetOnly(campaignId, targetId, targetPhoneNumber)`. First match wins:\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Guard | Check | Result\n\n| Target exists | `findCampaignTargetById` | `target_not_found`\n| DNC | `findDncTarget(targetPhoneNumber)` | `state = skipped`, reason `dnc`\n| Prior terminal disposition | a previous call to this number had a disposition in `SKIP_CALL_DISPOSITIONS` (`Do_Not_Call`, `Not_a_Seller`, `Amazon_Seller`) | `state = skipped`, `attempts = 9999`, reason `prior_disposition`\n| Contact lifecycle | the linked contact's stage is in `SKIP_CALL_LIFECYCLE_STAGES` (`customer`, `marketingqualifiedlead`) | `state = skipped`, `attempts = 9999`, reason `lifecycle_stage`\n| E.164 | `/^\\+[1-9]\\d{1,14}$/` | reason `invalid_e164`\n|===\n\nSetting `attempts = 9999` means that even a manual state reset cannot bring the target back into the\ndial pool.\n\nDNC and prior-disposition checks are keyed on the *phone number*, not the target id, so a Do Not Call\nrequest in one campaign suppresses the number in all of them.\n\n=== `initiate-call`\n\n`initiateCall(campaignId, campaignName, targetId, targetPhoneNumber, targetPayload, false)` — the final\n`false` skips re-validation, which the previous step already did.\n\n. *Select a source number* via `PhoneNumberSelector` — state first, then area code, then any available\n  number. See xref:domain:phone-numbers.adoc[].\n. *Pick a caller agent* — a random active `caller`-type agent for the workspace, falling back to\n  `RETELL_AGENT_LIST`.\n. *Build metadata* — `targetPayload` values are stringified and null-coalesced to `\"\"`, because a Retell\n  agent reads a missing variable's *name* aloud. `campaignId`, `campaignName`, `campaignTargetId`,\n  `agentId` and `agentVersionId` are added, and the whole object is sent as both `metadata` and\n  `retell_llm_dynamic_variables`.\n. *Create the Retell call.* On error: set the target to `failed`, increment `attempts`, record\n  `lastError`, and return `{ skipped: true, skipReason: retell_error }` — a provider outage should not\n  produce unhandled exceptions.\n. *Persist* — `upsertCallAndSetTargetDialing` writes the `Call` row (status `dialing`,\n  `providerCallId`, `phoneNumberId`, `agentId`, `aiAgentId`, `aiAgentName`, `agentVersionId`,\n  `subscriptionId`) and sets the target to `dialing` in one transaction. A failure here throws\n  `DatabaseError` and is *not* rolled back at Retell — the call is already dialing and will be tracked\n  by its webhook.\n\nSuccess returns `{ callId, providerCallId, phoneNumberId, isFallback }`.\n\n== Counter hygiene\n\nEvery exit path goes through a `finish()` helper that fire-and-forgets\n`decrementCallTargetRuns(campaignId)`, logging a failure without changing the result. A skipped or\nfailed dial must release its slot, or the campaign's in-flight ceiling would ratchet down until it\nstopped dialing entirely.\n\nThe double-tap path in `callEndedAnalysis` is the deliberate exception: it must decrement *before*\n`step.sleep`, so it calls `decrementCallTargetRuns` directly rather than through this helper.\n\n== What happens next\n\nThe function stops after initiation. Post-call work is triggered by the `call_analyzed` webhook, which\nemits `call-target/call.ended` — see xref:call-ended-analysis.adoc[].\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:call-ended-analysis.adoc","title":"callEndedAnalysis","description":"Post-call work — the double tap, contact and registry updates, and AI analysis.","url":"https://docx.payability.com/dialer-v2/workflows/call-ended-analysis.html","navOrder":58,"source":"= `callEndedAnalysis`\n:description: Post-call work — the double tap, contact and registry updates, and AI analysis.\n\nFile:: `lib/workflows/functions/call-ended-analysis.ts`\nEvent:: `call-target/call.ended`\nConfig:: `retries: 0`, `concurrency: { limit: 20 }`,\n`rateLimit: { key: \"event.data.callId\", limit: 1, period: \"24h\" }`\n\nThe rate limit keyed on `callId` guarantees that a call is post-processed at most once — so a duplicate\nwebhook cannot trigger a second double tap or a second analysis.\n\n== Flow\n\n[mermaid]\n....\nflowchart TD\n  A[\"call-target/call.ended\"] --> B{\"disconnectionReason in\\nDOUBLE_TAP_REASONS?\"}\n  B -->|Yes| C[\"check-double-tap-eligibility\"]\n  C --> D{\"First call today\\nfor this number\\nin this campaign?\"}\n  D -->|No| F[\"Fall through to analysis\"]\n  D -->|Yes| E{\"attempts < maxAttempts?\"}\n  E -->|No| F\n  E -->|Yes| G[\"step.sleep 1 minute\"]\n  G --> H[\"incrementCallTargetRuns\"]\n  H --> I[\"send call-target/call.requested\"]\n  I --> J[\"Return doubleTapScheduled\"]\n  B -->|No| K[\"update-contact-disposition\"]\n  F --> K\n  K --> L{\"disconnectionReason in\\nBLACKLISTED_AI_ANALYSIS_REASONS?\"}\n  L -->|Yes| M[\"Return — analysis skipped\"]\n  L -->|No| N[\"run-ai-analysis\"]\n....\n\n== Step 1 — double tap\n\nApplies when `disconnectionReason` is in `DOUBLE_TAP_REASONS`: `voicemail_reached`,\n`machine_detected`, `dial_no_answer`, `user_declined`.\n\n`check-double-tap-eligibility` requires both guards:\n\n. *First call today* — `countCallsTodayForTargetPhone(targetPhoneNumber, campaignId)` must equal `1`.\n  More than one call today to that number in that campaign cancels the double tap.\n. *Attempts remain* — `target.attempts < campaign.maxAttempts`. When `maxAttempts` is null this guard\n  does not apply.\n\nBoth reads come from existing repository methods, so the check adds no extra queries beyond the two it\nneeds.\n\nWhen allowed:\n\n. `step.sleep('double-tap-wait', '1 minute')` — long enough that the callee has put the phone down,\n  short enough to still feel like the same attempt.\n. `incrementCallTargetRuns(campaignId)` — the retry is a new in-flight call, and the broadcaster's cap\n  check must see it.\n. `step.sendEvent('double-tap', { name: 'call-target/call.requested', … })`.\n. Return `{ doubleTapScheduled: true, disconnectionReason }` — AI analysis is skipped, because the\n  outcome will be analysed after the retry.\n\nWhen *not* allowed, execution falls through to the remaining steps rather than returning early. The\ncall still deserves a contact update and, if eligible, an analysis.\n\n== Step 2 — contact and registry updates\n\nRuns when a `disposition` and a `subscriptionId` are present. Resolves the contact through the phone\nregistry (`TargetPhoneNumbers.contactId` for that number in that workspace).\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Outcome | Changes\n\n| DNC (`newState === \"DNC\"`)\n| Contact: `leadStatus = connected`, `lifecycleStage = other`, `stopCalling = true`,\n`nextAction = \"Do_Not_Call\"`. Registry: `isBlocked = true`, `updatedAt = now()`\n\n| Uncallable disconnection reason\n| Registry: `isBlocked = true`, `updatedAt = now()`. Contact fields are updated only when a\ndisposition is present. Reasons: `invalid_destination`, `telephony_provider_unavailable`,\n`error_retell`, `error_no_audio_received`, `marked_as_spam`,\n`telephony_provider_permission_denied`, `error_unknown`\n\n| Disposition `Amazon_Seller`\n| Contact: `leadStatus = connected`, `heatStatus = hot`,\n`lifecycleStage = marketingqualifiedlead`\n\n| Anything else\n| Contact: `leadStatus = attempted_to_contact`, `heatStatus = idle`,\n`lifecycleStage = opportunity`\n|===\n\nThe step also runs when there is no disposition, as long as the disconnection reason is uncallable\nand a `subscriptionId` is present. `TargetPhoneNumbers.updatedAt` has no `@updatedAt`, so the block\nwrite sets it explicitly.\n\nBlocking the registry row is what makes a Do Not Call request, or an uncallable number, permanent\nacross every campaign, present and future. `get_and_lease_due_targets` excludes blocked registry\nrows so the cron tick never leases them.\n\nReturns `{ contactUpdated, phoneBlocked, disposition, newState }` for observability. A call with no\nmatching registry row or contact simply reports `contactUpdated: false`.\n\n== Step 3 — analysis skip\n\nIf `disconnectionReason` is in `BLACKLISTED_AI_ANALYSIS_REASONS` the function returns:\n\n[source,json]\n----\n{ \"callId\": \"uuid\", \"analysisSaved\": false, \"analysisError\": \"Call was not connected to the target — analysis skipped\" }\n----\n\n[source]\n----\nvoicemail_reached, dial_busy, dial_no_answer, dial_failed,\ntelephony_provider_unavailable, user_declined, invalid_destination,\ntelephony_provider_permission_denied\n----\n\nFour of these also appear in `DOUBLE_TAP_REASONS`, so an *eligible* double tap returns before reaching\nhere — but an ineligible one falls through and is caught by this check.\n\n== Step 4 — AI analysis\n\n`run-ai-analysis` calls `CallAnalysisService.runAndSave(callId, campaignId)`.\n\nTransient infrastructure errors are swallowed and returned as data:\n\n[source,typescript]\n----\nconst isTransient =\n  msg.includes('EBUSY') ||\n  msg.includes('ENOTFOUND') ||\n  msg.includes('Cannot connect to API') ||\n  msg.includes('Insufficient funds')\n----\n\nAnything else re-throws, so a genuine bug reaches Sentry instead of being silently recorded as an\nunanalysed call.\n\nReturns `{ callId, analysisSaved, analysisError }`.\n\nSee xref:domain:call-analysis.adoc[] for agent selection and prompt composition.\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:email.adoc","title":"Email workflows","description":"emailSend, emailCampaignDuplicate and emailCampaignImportSegment.","url":"https://docx.payability.com/dialer-v2/workflows/email.html","navOrder":59,"source":"= Email workflows\n:description: emailSend, emailCampaignDuplicate and emailCampaignImportSegment.\n\n== `emailSend`\n\nFile:: `lib/workflows/functions/email-send.ts`\nEvent:: `email-campaign/send.requested`\nConfig:: `retries: 2`, `concurrency: { limit: 20 }`\n\nA commented-out `rateLimit` keyed on `event.data.email` (1 per 24 h) remains in the source; the\nduplicate-send guard is currently step 1 plus the broadcaster's `queuedAt` lock.\n\n`retries: 2` is safe because step 1 refuses any target that has already left `pending`, so a retry after\na successful send is a no-op.\n\n=== Event payload\n\n`subscriptionId`, `campaignId`, `targetId`, `email`, `subject`, `htmlBody`, `variationId`,\n`resendTemplateId`, `templateVariables`, `contactData`, `fromName`, `fromEmail`, `resendTopicId`,\n`unsubscribeUrl`, `firstName`, `lastName`, `sendingDomainId`.\n\nThe broadcaster resolves all of this — variation, sending account, contact fields, unsubscribe token —\nso the workflow does not have to re-derive it.\n\n=== Steps\n\n==== 1. `check-already-sent`\n\nRefuses when the target is missing, or its status is any of `requested`, `sent`, `opened`, `clicked`,\n`unsubscribed`, `bounced`, `blocked`, `suppressed`, `failed`. Returns `{ skipped: true, reason: \"already_sent\" }`.\n\nThe primary idempotency guard: a duplicate event, or a retry after a successful send, stops here.\n\n==== 2. `check-eligibility`\n\nOne read of `target_emails` through the target (`getTargetEmailEligibility`).\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Condition | Action\n\n| `newsletterOptOut` or `marketingOptOut` or `isBlocked` | Set the target `unsubscribed`; return `reason: \"unsubscribed\"`\n| `newsletterLastSentAt` newer than `newsletterGapDays` ago (default 7) | Clear the queue lock so the row can be re-leased later; return `reason: \"newsletter_gap\"`\n|===\n\nThe cadence gap is *cross-campaign*: without it, an address enrolled in three campaigns would get three\nemails the same morning.\n\nClearing the lock rather than failing the target is what lets the row come back once the gap has\nelapsed.\n\n==== 3. `check-send-limits`\n\nLoads the campaign, the assigned variation, today's send count and the per-variation hourly counts in\none `Promise.all`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Condition | Result\n\n| Campaign or variation missing | `skip: \"not_found\"`\n| `sentToday >= maxEmailsPerDay` | `skip: \"daily_limit_reached\"`\n| Assigned variation under `emailsPerVariationPerHour` (default 100) | Proceed\n| Assigned variation over the rate, another active variation under it | Proceed with that `fallback` variation\n| All active variations over the rate | Release the queue lock; `skip: \"variation_rate_limited\"`\n|===\n\nLimits are re-checked even though the broadcaster already checked them, because minutes pass between\nleasing and sending and other campaigns may have consumed the budget.\n\nWhen a fallback is used, *all* of subject, HTML body, Resend template id and template variables switch\nto the fallback's — not just the id.\n\n==== 4. `send-email`\n\nResolves template variables: for each `{ key, type, fallbackValue }`, take `contactData[key]` when it is\npresent and non-empty, else `fallbackValue`; coerce to a number when `type` is `number`\n(`Number(value) || 0`); drop the variable entirely when the result is null.\n\n`UNSUBSCRIBE_LINK` is set *last*, so no user-defined variable can overwrite it.\n\n`from` is `\"<fromName> <fromEmail>\"`; `replyTo` comes from `buildReplyToForSendingDomain`, which is what\nmakes inbound reply correlation possible.\n\nSends through `ResendEmailClient.sendCampaign` with the resolved subject, HTML, optional\n`resendTemplateId`, `topicId`, variable values and unsubscribe URL, plus a `target_id` tag\n(`RESEND_TARGET_ID_TAG`) holding the target id. Returns the Resend `id`.\n\nThe tag lets `ResendWebhookDispatcher` match a webhook that arrives before step 5 saves\n`resendEmailId`. See xref:domain:email-campaigns.adoc#_matching_webhooks_to_targets[Matching webhooks to targets].\n\n==== 5. `update-target`\n\n* `markTargetRequested` stores `resendEmailId`, `variationId` (the *effective* one), `sendingDomainId`\n  and `sentAt`, and sets status → `requested` only while it is still `pending`, so a status a webhook\n  already set (`sent`, `opened`, `suppressed`) is kept.\n* `touchTargetEmailNewsletterLastSent(emailId)` — stamps the cross-campaign cadence clock.\n* `incrementSendingDomainDailyCount(sendingDomainId)` — reflects the send against the daily cap\n  immediately, rather than waiting for the `email.sent` webhook. The lifetime `sentCount` is bumped by\n  that webhook, so the two do not double-count.\n* `incrementVariationUsage(effectiveVariationId)`.\n\n==== 6. `log-activity-and-crm`\n\nOne read for `contactId` and `hubspotContactId`, reused twice:\n\n* Write an `Activity` row (type `email`, body truncated to 500 characters).\n* `publishCrmNewActivity` with `direction: \"EMAIL\"` — only when a HubSpot contact id exists.\n\nBoth are best-effort: failures are warned and the step still succeeds. The email has already been sent;\nfailing the run over a log entry would be worse than losing the log entry.\n\n== `emailCampaignDuplicate`\n\nFile:: `lib/workflows/functions/email-campaign-duplicate.ts`\nEvent:: `email-campaign/duplicate.requested`\nConfig:: `retries: 2`, `concurrency: { limit: 5 }`\n\n`+POST /api/email-campaigns/{id}/duplicate+` creates the destination campaign shell synchronously — a\n`draft` copy named `\"<name> (copy)\"` with the source's email configuration and a *fresh* Resend topic —\nthen emits this event.\n\n=== Payload\n\n`{ subscriptionId, sourceCampaignId, newCampaignId }`\n\n=== Behaviour\n\nA cursor-paged loop, bounded by `MAX_ITERATIONS`:\n\n[source,typescript]\n----\nlet cursor = ZERO_UUID\nfor (let i = 0; i < MAX_ITERATIONS; i++) {\n  const result = await step.run(`copy-chunk-${i}`, () =>\n    emailRepo.copyValidTargetsChunk(sourceCampaignId, newCampaignId, cursor, CHUNK),\n  )\n  totalInserted += result.inserted\n  totalScanned  += result.scanned\n  if (result.scanned < CHUNK || !result.nextCursor) break\n  cursor = result.nextCursor\n}\n----\n\nThen a `finalize` step logs and returns\n`{ sourceCampaignId, newCampaignId, inserted, scanned }`.\n\nPoints worth noting:\n\n* Each chunk is its own `step.run`, so a failure mid-copy resumes from the last completed chunk rather\n  than restarting.\n* The cursor starts at `ZERO_UUID` and advances by target id, giving a stable ordering that tolerates\n  concurrent inserts.\n* `inserted` and `scanned` differ because only *valid* targets are copied — suppressed and blocked\n  addresses are scanned and skipped. Comparing the two tells an operator how much of the source list is\n  no longer contactable.\n* The new campaign starts in `draft`, so it cannot begin sending before its recipients have arrived.\n\n== `emailCampaignImportSegment`\n\nFile:: `lib/workflows/functions/email-campaign-import-segment.ts`\nEvent:: `email-campaign/import-segment.requested`\nConfig:: `retries: 2`, `concurrency: { limit: 5 }`\n\nEmitted by `+POST /api/email-campaigns/{id}/targets/from-segment+` (`202`) and, optionally, by\n`+POST /api/email-campaigns+` when `segment_id` is set. This is a *one-shot snapshot* of the segment's\ncurrent active members — not a live audience, and `Campaign` does not store `segmentId`.\n\n=== Payload\n\n`{ subscriptionId, campaignId, segmentId }`\n\n=== Behaviour\n\nA `verify` step no-ops when the campaign is missing, not `type = email`, or not in the event's\nsubscription, or when the segment is missing or inactive.\n\nThen a cursor-paged loop calling `copySegmentMembersChunk`, ordered by\n`priorityScore DESC, id DESC` with a composite `(priorityScore, id)` keyset:\n\n[source,typescript]\n----\nlet cursor: SegmentMemberCopyCursor | null = null\nfor (let i = 0; i < MAX_ITERATIONS; i++) {\n  const result = await step.run(`copy-chunk-${i}`, () =>\n    emailRepo.copySegmentMembersChunk(subscriptionId, segmentId, campaignId, cursor, CHUNK),\n  )\n  if (result.scanned < CHUNK || !result.nextCursor) break\n  cursor = result.nextCursor\n}\n----\n\nEligibility is SQL-only (no HubSpot hydration): open membership (`removedAt IS NULL`), a local\n`Contact` (by `contactId`, or HubSpot id when `contactId` is null), `Contact.email`, and a sendable\n`target_emails` row (not deleted / opted out / blocked). One address per contact, matching\n`@@unique([campaignId, contactId])`. `ON CONFLICT DO NOTHING` makes re-imports and step replays\nidempotent.\n\n`finalize` logs campaign/segment ids and `{ inserted, scanned }` only — no PII.\n\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:sequence-execute.adoc","title":"sequenceExecute","description":"Running a contact's entire nurture journey in one durable run.","url":"https://docx.payability.com/dialer-v2/workflows/sequence-execute.html","navOrder":60,"source":"= `sequenceExecute`\n:description: Running a contact's entire nurture journey in one durable run.\n\nFile:: `lib/workflows/functions/sequence-execute.ts`\nEvent:: `sequence/execute`\nConfig:: `retries: 1`, `concurrency: { limit: 50 }`,\n`cancelOn: [{ event: \"sequence/cancel\", match: \"data.contactSequenceId\" }]`\n\nOne run drives one `ContactSequence` from enrollment to completion. The Inngest run id is stamped on the\nenrollment as `executionId`, and every `ContactSequenceStep` log produced by the run shares it — so the\nwhole journey is observable both in the database and the Inngest dashboard.\n\n== Why one run, not one per step\n\nA step-per-run design would need a scheduler to find due enrollments, a lock to stop two runs advancing\nthe same one, and a way to reconstruct context on every wake-up. A single run instead:\n\n* loads the template and contact *once* and indexes them in memory;\n* uses `step.sleep` for waits, which consumes no concurrency;\n* relies on `cancelOn` for stopping, so no per-iteration ownership check is needed;\n* gives one `executionId` that ties the enrollment, its logs and the dashboard run together.\n\n== Payload\n\n[source,typescript]\n----\ninterface SequenceExecuteEventData {\n  contactSequenceId: string\n  subscriptionId: string\n}\n----\n\n== Initialisation\n\n`step.run(\"start-execution\")` → `service.startExecution(contactSequenceId, runId)`:\n\n* loads the enrollment with its template steps and contact;\n* refuses to restart a terminal sequence;\n* claims the run by stamping `executionId = runId` and marking the enrollment `active`;\n* returns the immutable run context.\n\nEarly exits:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Condition | Return\n\n| Not active, or no context | `{ executionId, done: false, reason: \"stopped_or_superseded\" }`\n| Template has no steps | `{ executionId, started: false, reason: \"template_has_no_steps\" }`\n|===\n\n== The loop\n\nBounded by `MAX_ITERATIONS = 1000` as defence in depth — gate retries are already capped by\n`gateMaxRetries`, so this only catches a mis-wired branch cycling forever. Exhausting it runs\n`abandon-runaway`.\n\nEach iteration builds `buildStepContext(base, stepIndex)` in memory — no database read.\n\n=== No step at this index\n\n`complete-no-step-<i>` applies `{ kind: \"complete\" }` and returns\n`{ done: true, reason: \"no_step_at_index\" }`.\n\n=== `wait` channel\n\n. `step.sleep(\"wait-<tag>\", \"<delayHours><h|m>\")` when `delayHours > 0`. The unit comes from\n  `delayUnit`.\n. `step.run(\"record-wait-<tag>\")` → `markRunning` then `recordWait`, writing a lightweight log with no\n  response payload so the timeline stays legible.\n\n=== `data_gate` channel\n\nOn a re-check (`gateRetryCount > 0` and `gateRetryDelayHours > 0`), sleep the retry delay first, then\n`step.run(\"check-gate-<tag>\")` → `evaluateGate`. Marking the step running is folded into the same\n`step.run` so it is not a separate durable step.\n\n`evaluateGate`:\n\n* resolves `+{{token}}+` references in condition values against the contact;\n* requires `gateBigqueryView` and at least one condition, else throws;\n* with no `contact.hubspotContactId` there is nothing to scope the query by, so every condition is\n  reported as not met — the gate fails closed;\n* otherwise runs `queryGateConditions` against BigQuery with the identifier bound as a parameter and the\n  view/field names allowlist-validated.\n\n=== Other channels\n\n`step.run(\"execute-action-<tag>\")` → `executeAction(ctx, runId)`, which switches on the channel and\nreturns `{ status, outcome, eventBody? }` alongside the artifact ids it produced. See\nxref:domain:sequences.adoc#action-semantics[Action semantics].\n\n=== Advancing\n\n`step.run(\"finish-<tag>\")` applies the decision from\n`computeAdvance(step, totalSteps, gateResult, gateRetryCount)`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Decision | Effect\n\n| `advance` | Move to `nextStepIndex`, reset `gateRetryCount`\n| `retry` | Re-enter the same gate after `delayHours`, incrementing `gateRetryCount`\n| `complete` | Mark the enrollment `completed`\n| `abandon` | Mark the enrollment `abandoned`\n|===\n\nA gate with retries configured but no `falseBranchStepIndex`, having exhausted its retries,\n*abandons* — \"we waited as long as we agreed to and the condition never became true\" is a real outcome,\nnot a step to skip.\n\n== Step ids\n\nIds embed the step index, retry count, channel and a slugified step name — for example\n`execute-action-3-0-email-intro-follow-up`. The slug is derived from the memoised context, so ids stay\nstable across replays while remaining readable in the dashboard.\n\n== Cancellation\n\n`+POST /api/contact-sequences/{id}/stop+` sends `sequence/cancel`. Because `cancelOn` matches on\n`data.contactSequenceId`, the run is torn down immediately — including an in-progress `step.sleep`.\nThis is why the loop body has no ownership re-check.\n"},{"component":"dialer-v2","module":"workflows","path":"workflows:utility.adoc","title":"Analysis, purchase and utility workflows","description":"nooksCallAnalysis, purchasePhoneNumber, batchCallAnalysisWorkflow and the scaffolding functions.","url":"https://docx.payability.com/dialer-v2/workflows/utility.html","navOrder":61,"source":"= Analysis, purchase and utility workflows\n:description: nooksCallAnalysis, purchasePhoneNumber, batchCallAnalysisWorkflow and the scaffolding functions.\n\n== `nooksCallAnalysis`\n\nFile:: `lib/workflows/functions/nooks-call-analysis.ts`\nEvent:: `nooks/call.logged`\nConfig:: `retries: 0`, `concurrency: { limit: 10 }`,\n`rateLimit: { key: \"event.data.callId\", limit: 1, period: \"24h\" }`\n\nPayload:: `{ callId, subscriptionId, disposition }`\n\nA single `run-ai-analysis` step calls `CallAnalysisService.runAndSave(callId, null)`. The `null`\ncampaign id matters: a Nooks call has no campaign, so the workspace's default analysis agent is used and\nthe result is persisted with `campaignId = null`.\n\nBecause there is no caller agent either, the system prompt is\n`DEFAULT_HUMAN_CALL_ANALYSIS_SYSTEM_PROMPT` — a sales-coaching frame that asks whether the logged\ndisposition matches the conversation, how objections and rapport were handled, and whether there are\ncompliance or tone issues.\n\nThe event is only emitted for dispositions *outside* `NOOKS_ANALYSIS_SKIP_DISPOSITIONS`\n(`\"No Answer\"`, `\"Voicemail\"`, `\"Busy\"`) — the handler filters before emitting. See\nxref:events:nooks.adoc[].\n\n== `purchasePhoneNumber`\n\nFile:: `lib/workflows/functions/purchase-phone-number.ts`\nEvent:: `phone-number/purchase.requested`\nConfig:: `retries: 0`, `concurrency: { limit: 1 }`,\n`idempotency: 'event.data.subscriptionId + \"-\" + event.data.state + \"-\" + event.data.areaCode'`\n\nPayload:: `{ state, areaCode, subscriptionId, campaignId, targetId }`\n\n[IMPORTANT]\n====\nThe function body is entirely commented out — phone-number purchasing is currently *manual*. The\nfunction is still registered, so events are consumed and complete without doing anything.\n====\n\nThe intended design, preserved in the comments:\n\n. *`check-existing`* — decide whether a purchase is needed:\n* the state already has 3 or more active numbers → skip (`enough_numbers`);\n* the state has none → purchase (`no_numbers_in_state`);\n* some numbers exist and at least one has never called this target → skip\n  (`unused_number_available`), because the selector can use that fresh number on the next tick;\n* all existing numbers in the state have already called this target → purchase\n  (`all_numbers_used_for_target`).\n. *`purchase`* — buy from MessagingApp and register with Retell; throws on failure.\n. *`store`* — persist to the database; on failure, clean up at both Retell and the phone service.\n\nThe idempotency key stops two targets in the same state and area code from triggering two purchases,\nand `concurrency: { limit: 1 }` serialises purchasing globally so a burst cannot overspend.\n\nTo re-enable: uncomment the body, then enable both `Configs` keys — `purchase_new_phone_numbers` and\n`daily_phone_number_purchase_limit`. The selector's `skipForPurchase` branch and\n`CallSkipReason.NO_PHONE_NUMBER_AVAILABLE` are still in place for that path.\n\n[#batch-call-analysis]\n== `batchCallAnalysisWorkflow`\n\nFile:: `lib/workflows/batch-call-analysis-workflow.ts`\n\nNot an Inngest function. It uses the `'use workflow'` / `'use step'` directive runtime (the `workflow`\npackage) and is started fire-and-forget from `POST /api/calls/batch-analyze`.\n\n[source,typescript]\n----\nexport interface BatchCallAnalysisFilters {\n  campaign_id?: string | null\n  status?: string | null\n  date_from?: string | null\n  date_to?: string | null\n  hubspot_sync_status?: string | null\n}\n\nexport interface BatchCallAnalysisWorkflowResult {\n  total: number\n  saved: number\n  errors: Array<{ callId: string; error: string }>\n}\n----\n\nSteps:\n\n. *`getCallsToAnalyzeStep(filters)`* — `CallRepository.findCallsForBatchAnalysis(...)`, then filters out\n  calls with a null `campaignId` and maps to `{ callId, campaignId }`. The workflow never touches\n  Prisma; the repository is imported dynamically inside the step.\n. *`runCallAnalysisAndSaveStep(callId, campaignId)`* — `CallAnalysisService.runAndSave`, the same code\n  path as per-call analysis, so there is no second implementation to drift.\n\nThe loop accumulates `saved` and pushes `{ callId, error }` for failures, so a partial run reports\nexactly which calls could not be analysed.\n\n`findCallsForBatchAnalysis` and `countCallsForBatchAnalysis` share the private\n`buildBatchAnalysisWhere(filters)` helper, which is why the `GET` preview count and the executed set\ncannot disagree.\n\nCalls whose `campaignId` is null — Nooks calls — are excluded from batch re-analysis. Re-run those\nindividually with `+POST /api/calls/{id}/analyze+`.\n\n== Scaffolding functions\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Function | File\n\n| `processRequest` | `lib/workflows/functions/test-function.ts`\n| `testContextCollection` | `lib/workflows/functions/test-context-collection.ts`\n|===\n\nRegistered so the Inngest wiring can be exercised end to end. `lib/workflows/call-target-workflow.ts`\nis an older directive-runtime variant of the dial pipeline, superseded by the `callTarget` Inngest\nfunction.\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:index.adoc","title":"Integrations","description":"Every external system Dialer V2 talks to, and how it is wired.","url":"https://docx.payability.com/dialer-v2/integrations/index.html","navOrder":62,"source":"= Integrations\n:description: Every external system Dialer V2 talks to, and how it is wired.\n\n[cols=\"1,2,1,1\",options=\"header\"]\n|===\n| System | Role | Direction | Page\n\n| Retell AI | AI voice calls, SMS chats, agent configuration | out + webhooks in | xref:retell.adoc[]\n| Resend | Campaign and transactional email, delivery events | out + webhooks in | xref:resend.adoc[]\n| HubSpot | CRM contacts, companies, engagements, tasks, deals | out via SQS + webhooks in | xref:hubspot.adoc[]\n| Nooks | Human-dialer call outcomes | webhooks in | xref:telephony.adoc[]\n| Twilio / Plivo | Carrier numbers imported into Retell over SIP | out | xref:telephony.adoc[]\n| MessagingApp | Internal microservice that buys and releases numbers | out | xref:telephony.adoc[]\n| Five9 | Call payload capture (stored only) | webhooks in | xref:telephony.adoc[]\n| JustCall | Legacy telephony and agent availability | webhooks in | xref:telephony.adoc[]\n| Supabase | PostgreSQL, Auth, Realtime, Edge Functions | both | xref:platform-services.adoc[]\n| Vercel AI Gateway | One endpoint fronting OpenAI, Anthropic, xAI, Google | out | xref:platform-services.adoc[]\n| Google BigQuery | Read-only CRM/engagement mirror for sequence data gates | out | xref:platform-services.adoc[]\n| AWS SQS | CRM and email-categorisation message queues | out | xref:platform-services.adoc[]\n| Redis | In-flight call counter | both | xref:platform-services.adoc[]\n| Sentry | Error and performance monitoring | out | xref:platform-services.adoc[]\n|===\n\n== Wiring conventions\n\nClients live in `lib/plugins/` or a dedicated folder::\n`lib/plugins/` holds `inngest-client.ts`, `redis-client.ts`, `resend-client.ts`, and `sqs-client.ts`.\nHubSpot HTTP clients live in `lib/plugins/hubspot/` (`HubSpotClient` plus feature clients such as\n`HubSpotListsClient`; `pyb.ts` wraps `@payability/pyb-hubspot`). Retell has its own folder\n(`lib/retellai/`) because it carries types, agent config and the post-call analysis schema alongside\nthe client.\n\nErrors are wrapped, not leaked::\n`RetellClient` wraps SDK failures in `RetellError`; the calling service converts them to\n`RetellCallError` or a `CallSkipReason`. A provider's error shape never reaches a route handler.\n\nConfiguration is registry-driven where it varies::\nCarriers are entries in `EXTERNAL_PROVIDER_CONFIGS`, models in `AI_GATEWAY_MODELS`, CRM event types in\n`CRM_EVENT_TYPES`. Adding one is a single-entry change.\n\nMissing configuration degrades rather than crashes::\nRealtime broadcast logs a warning and returns when Supabase keys are absent; Svix verification is skipped\nwhen the Resend secret is unset; SQS publishers return `false` when a queue URL is missing. The two\nexceptions that fail closed are `verifyInternalSecret()` and HubSpot signature verification, because\nboth guard write paths.\n\nBest-effort side effects are explicit::\nActivity logging, HubSpot engagement logging and CRM publishing are wrapped in `try/catch` that warns and\ncontinues. The comment `// best-effort` marks them in the source.\n\n== Environment variables\n\nSee xref:ops:environment-variables.adoc[] for the full list with descriptions.\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:retell.adoc","title":"Retell AI","description":"AI voice calling, SMS chats, agent management and phone-number import.","url":"https://docx.payability.com/dialer-v2/integrations/retell.html","navOrder":63,"source":"= Retell AI\n:description: AI voice calling, SMS chats, agent management and phone-number import.\n\nRetell AI runs the conversations. The application creates calls, manages agents and imports numbers\nthrough Retell's API, and receives call outcomes as webhooks.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Concern | File\n\n| Client wrapper | `lib/retellai/client.ts`\n| Types | `lib/retellai/types.ts`\n| Agent config, voice and model lists | `lib/retellai/agent-config.ts`\n| Post-call analysis schema | `lib/retellai/post-call-analysis.ts`\n| Agent platform operations | `lib/services/retell-agent-service.ts`\n| Agent definitions and sync script | `scripts/retelai/`\n|===\n\n== Configuration\n\n[source,bash]\n----\nNEXT_RETELLAI_API_KEY=          # read into RETELLAI_API_KEY in lib/constants.ts\nNEXT_RETELLAI_BASE_URL=         # optional override\nNEXT_RETELL_OUTBOUND_AGENT_ID=  # default outbound agent\n----\n\n`RETELL_SMS_AGENT_ID` is hardcoded in `lib/constants.ts` as\n`agent_dc036c59f7365da0f463b16e72` — every outbound SMS uses it.\n\n== Client surface\n\n`RetellClient` (`lib/retellai/client.ts`) wraps `retell-sdk`. Every method goes through a private\n`wrapCall` helper that converts SDK failures into `RetellError`, so callers never see the SDK's error\nshape.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Method | Purpose\n\n| `createPhoneCall(options)` | Outbound call: `fromNumber`, `toNumber`, `overrideAgentId`, `metadata`, `retellLlmDynamicVariables`\n| `createWebCall(options)` | Browser-based call\n| `getCall(callId)`, `updateCall(callId, updates)`, `deleteCall(callId)` | Call record management\n| `createSmsChat(options)` | Start an SMS conversation\n| `createRetellPhoneNumber(...)` | Buy a number through Retell\n| `registerPhoneNumber(phoneNumber)` | Register an existing number\n| `importNumber(phoneNumber, terminationUri, outboundAgentId)` | Import a carrier number over SIP\n| `unregisterPhoneNumber(phoneNumber)` | Remove a number\n| `getPhoneNumber(id)`, `listPhoneNumbers()` | Number inspection\n| `listVoices()`, `getVoice(voiceId)` | Voice catalogue, exposed at `GET /api/retell/voices`\n|===\n\n== Outbound call\n\n[source,typescript]\n----\nawait retellClient.createPhoneCall({\n  fromNumber: phoneSelection.phoneNumber,\n  toNumber: targetPhoneNumber,\n  overrideAgentId: overrideAgent.agentId,\n  metadata: callMetadata,\n  retellLlmDynamicVariables: callMetadata,\n})\n----\n\nThe same object is passed as both `metadata` and `retellLlmDynamicVariables`, because the webhook handler\nreads `metadata.campaignId` and `metadata.campaignTargetId` for correlation, while the agent prompt reads\nthe dynamic variables.\n\n`callMetadata` contains the target's `payloadJson` (every value stringified and null-coalesced to `\"\"`,\nbecause an agent reads a missing variable's *name* aloud) plus `campaignId`, `campaignName`,\n`campaignTargetId`, `agentId` and `agentVersionId`.\n\n== Agent management\n\n`RetellAgentService` performs platform operations, mirrored from every write to `/api/ai-agents`:\n\n`createAgent`, `getAgent`, `updateAgent`, `deleteAgent`, `publishAgent(retellAgentId, version)`,\n`createAgentVersion(retellAgentId, baseVersion)`, `createLLM`, `getLLM`, `updateLLM`, `syncAgent(...)`.\n\n`AiAgent.retellAgentId` is the link, and `AiAgentVersion.configJson` holds the full Retell `IAgentData`\nfor caller agents. See xref:domain:ai-agents.adoc[].\n\n=== Agent definitions in the repository\n\n`scripts/retelai/agents/<name>/` holds `agent.md` (the prompt, version-controlled) and `index.ts` (the\nconfig). `registry.ts` is the single source of the fallback agent id/name list used when a workspace has\nno database caller agents, and `sync.ts` pushes definitions to Retell.\n\nAgents currently defined: `adrian`, `brian`, `cmon`, `kate`, `kathrine`.\n\n=== Fixed configuration\n\n`HARDCODED_AGENT_CONFIG` in `lib/retellai/agent-config.ts` is applied to every caller agent:\n`stt_mode: 'fast'`, `end_call_after_silence_ms: 25000`, `max_call_duration_ms: 80000`,\n`ring_duration_ms: 30000`, `begin_message_delay_ms: 1000`, `allow_user_dtmf: true` with a 5 s keypress\ntimeout, `is_public: false`, `vocab_specialization: 'general'`. Rationale for each is in\nxref:domain:ai-agents.adoc#voice-and-behaviour-configuration[Voice and behaviour configuration].\n\n== Phone-number import\n\nA carrier number is imported over SIP using the provider's trunk and termination URL from\n`EXTERNAL_PROVIDER_CONFIGS`:\n\n[source,typescript]\n----\nawait retellClient.importNumber(phoneNumber, terminationUrl, outboundAgentId)\n----\n\nThe resulting Retell id is stored as `PhoneNumbers.retellPhoneNumberId`. See\nxref:telephony.adoc[].\n\n== Webhooks\n\nRetell posts to `POST /api/webhooks/event-logger`. Only `call_analyzed` is handled; `call_started` is\nstored but not dispatched. There is no signature verification on this endpoint — treat the URL as a\nsecret. See xref:events:retell.adoc[].\n\nKey payload paths:\n\n[source]\n----\ncall.call_id\ncall.to_number, call.from_number, call.direction\ncall.disconnection_reason, call.channel_type\ncall.duration_ms, call.call_cost.combined_cost\ncall.start_timestamp, call.end_timestamp\ncall.recording_url, call.transcript\ncall.call_analysis.in_voicemail\ncall.call_analysis.call_screening_detected\ncall.call_analysis.ai_suspicion_detected\ncall.call_analysis.user_sentiment\ncall.call_analysis.call_summary\ncall.call_analysis.call_successful\ncall.call_analysis.custom_analysis_data.*\ncall.metadata.campaignId\ncall.metadata.campaignTargetId\n----\n\n== SMS\n\n`createSmsChat` starts a conversation with `RETELL_SMS_AGENT_ID`, a `from` number selected from the\nworkspace's `smsEnabled` numbers, and every caller-supplied field forwarded as a Retell dynamic\nvariable. Used by the `send_sms` MCP tool and by the sequence `sms` step. See\nxref:api:mcp.adoc#send-sms[send_sms].\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:resend.adoc","title":"Resend","description":"Campaign and transactional email, templates, topics, contacts and delivery webhooks.","url":"https://docx.payability.com/dialer-v2/integrations/resend.html","navOrder":64,"source":"= Resend\n:description: Campaign and transactional email, templates, topics, contacts and delivery webhooks.\n\nResend is the email provider for campaigns, sequence emails, manual replies and the `send_email` MCP\ntool.\n\nClient:: `lib/plugins/resend-client.ts` — `ResendEmailClient`\nTopics:: `lib/services/resend-topic-service.ts`\nWebhooks:: `POST /api/webhooks/resend`, dispatched by `lib/events/resend-event-handler.ts`\n\n== Configuration\n\n[source,bash]\n----\nRESEND_API_KEY=\nRESEND_WEBHOOK_SECRET=   # Svix verification; when unset, verification is skipped\n----\n\n== Client surface\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Method | Purpose\n\n| `listDomains()` | Verified domains, exposed at `GET /api/email-domains/resend-domains`\n| `send(options)` | One-off transactional send\n| `sendCampaign(options)` | Campaign send with template, topic, variables and unsubscribe headers\n| `upsertContact(...)` | Create or update a Resend contact for topic membership\n| `createVariationTemplate(...)` | Create a Resend template from an `EmailVariation`\n| `updateVariationTemplate(...)` | Keep the Resend template in sync\n| `getEmailContentById(emailId)` | Fetch a sent message's content\n|===\n\n== Sending model\n\nResend templates are the canonical content store. An `EmailVariation` holds the subject and HTML in the\ndatabase *and* a `resendTemplateId`, kept in sync on create and update. `templateType` is always\n`\"custom\"`.\n\n`sendCampaign` receives:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Field | Source\n\n| `from` | `\"<fromName> <fromEmail>\"` from the selected `EmailSendingDomain`\n| `replyTo` | `buildReplyToForSendingDomain({ fromEmail, fromName })`\n| `to` | The recipient address\n| `subject`, `html` | The effective variation\n| `resendTemplateId` | The variation's Resend template, when present\n| `topicId` | `Campaign.resendTopicId`\n| `templateVariableValues` | Resolved from contact fields with per-variable fallbacks\n| `unsubscribeUrl` | `/api/unsubscribe?token=<base64url targetId>`\n|===\n\n`UNSUBSCRIBE_LINK` is always included and applied *last*, so no user-defined variable can overwrite it.\nResend substitutes `+{{{UNSUBSCRIBE_LINK}}}+` in both template and raw-HTML sends.\n\n== Topics\n\nA topic segments an audience and gives Resend-managed unsubscribe alongside the application's own.\n`createResendTopicForCampaign(name, campaignId)` provisions one; the id is stored as\n`Campaign.resendTopicId`. Duplicating a campaign creates a *fresh* topic rather than sharing the\nsource's, so the copies' audiences stay independent.\n\n`upsertContact` registers the recipient before sending, which is what makes topic membership and\nplatform-level unsubscribe work.\n\n== Unsubscribe headers (RFC 8058)\n\nEvery campaign email carries `List-Unsubscribe` and `List-Unsubscribe-Post` pointing at\n`/api/unsubscribe`, plus an in-body link using the same URL. One-click opt-out sets\n`TargetEmails.newsletterOptOut`, suppressing the address across every campaign in the workspace, and\npublishes `Contact.Unsubscribe` to the CRM queue.\n\n== Webhooks\n\nTen event types are handled: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.opened`,\n`email.clicked`, `email.bounced`, `email.complained`, `email.suppressed`, `email.unsubscribed`, `email.received`. The route verifies the Svix\nsignature when the secret is set, persists the raw event idempotently on the `svix-id` header, and\ndispatches through `ResendWebhookDispatcher`.\n\nSee xref:events:resend.adoc[] for per-handler effects and inbound reply correlation.\n\n== Domain reputation\n\nTwo levels of sender configuration, with independent daily caps: `Domain` (a Resend-verified domain) and\n`EmailSendingDomain` (a concrete `From` address). Each Resend subdomain is its own verified domain, so it\nbecomes its own `Domain` row rather than a child. See xref:domain:email-deliverability.adoc[].\n\nPer-account counters (`sentCount`, `receivedCount`, `openedCount`, `clickedCount`, `bouncedCount`,\n`unsubscribedCount`) are maintained by the webhook handlers and are the practical signal for spotting an\naccount that is damaging the domain's reputation.\n\n== Failure behaviour\n\n* No `RESEND_API_KEY` — sends fail; the `emailSend` workflow retries twice, then the target is left for a\n  later tick.\n* No `RESEND_WEBHOOK_SECRET` — signature verification is skipped. Acceptable in development, not in\n  production.\n* A Resend outage leaves targets in `pending` with their queue lock expiring, so they are re-leased\n  automatically once the provider recovers.\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:hubspot.adoc","title":"HubSpot","description":"CRM synchronisation over SQS, inbound webhooks, and the direct calls that remain.","url":"https://docx.payability.com/dialer-v2/integrations/hubspot.html","navOrder":65,"source":"= HubSpot\n:description: CRM synchronisation over SQS, inbound webhooks, and the direct calls that remain.\n\nHubSpot is the CRM of record for the sales team. The application mirrors identity into it and hands off\nqualified outcomes, but does not treat it as the source of truth for contactability — that lives in the\nregistries.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Concern | File\n\n| Client wrapper | `lib/plugins/hubspot/` — `HubSpotClient` base, `HubSpotListsClient`, `pyb.ts` (`@payability/pyb-hubspot`)\n| CRM queue publishers | `lib/plugins/sqs-client.ts`\n| Webhook signature | `lib/utils/hubspot/webhook-signature.ts`\n| Webhook normalisation | `lib/utils/hubspot/webhook-normalize.ts`\n| Inbound endpoint | `app/api/webhooks/hubspot/route.ts`\n|===\n\n== Configuration\n\n[source,bash]\n----\nHUBSPOT_PRIVATE_APP_TOKEN=\nHUBSPOT_WEBHOOK_CLIENT_SECRET=   # required — the inbound route rejects when unset\nHUBSPOT_CLIENT_ID=\nHUBSPOT_CLIENT_SECRET=\nHUBSPOT_REFRESH_TOKEN=\nHUBSPOT_OWNER_ID=                # read into HUBSPOT_AI_OWNER_ID, default '86817215'\nCRM_QUEUE_URL=\n----\n\nConstants in `lib/constants.ts`: `HUBSPOT_PORTAL_ID` (`\"9298797\"`), `HUBSPOT_AI_OWNER_ID`,\n`HUBSPOT_TASK_OWNER_ID` (`'550592045'`), `HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID` (`\"44467216\"`),\n`HUBSPOT_LIFECYCLE_STAGE_PROPERTY` (`'lifecyclestage'`).\n\n== Object mapping\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Local | HubSpot | Link field\n\n| `Seller` | Company | `Seller.hubspotCompanyId`\n| `Contact` | Contact | `Contact.hubspotContactId`\n| `Call` | Engagement | `Call.hubspotEngagementId`\n| `FollowUpTask` | Task | `FollowUpTask.hubspotTaskId`\n| `Activity` | Engagement | `Activity.hubspotEngagementId`\n|===\n\nContacts also mirror HubSpot's own vocabulary: `ContactLifecycleStage` mirrors `lifecyclestage` and\n`ContactLeadStatus` mirrors `hs_lead_status`, so no translation layer is needed.\n\n== Outbound: the CRM queue\n\nWrites go to SQS, not to the HubSpot API. A downstream Payability service applies them.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| `event_type` | Producer | Meaning\n\n| `Crm.DialerCallEnded` | Retell `call_analyzed` handler | An eligible AI call finished\n| `Crm.DealUpdate` | Nooks `call.logged` handler | Move a deal stage, e.g. `closedlost`\n| `Crm.NewActivity` | `emailSend`, Resend handlers | An outbound touch was made\n| `Crm.CustomEvent` | Resend handlers | Custom timeline event\n| `Crm.TaskCreate` | Sequence `crm_task` step | Create a task\n| `Contact.Update` | Sequence `crm_event` step | Update contact properties\n| `Contact.Unsubscribe` | `/api/unsubscribe` | Record an opt-out\n| `Crm.HubSpotWebhook` | Inbound webhook route | A normalised HubSpot notification\n|===\n\nQueueing rather than calling directly means a HubSpot outage or rate limit never blocks a call from\ncompleting or an email from sending, and one service owns all CRM reconciliation.\n\n=== The conditional-publish rule\n\nA CRM message is published *only* when a HubSpot contact id is available:\n\n* Resend handlers check for the id before publishing.\n* The sequence `crm_task` step skips with `no_hubspot_contact`.\n* The Nooks closed-lost flow logs a warning and sends nothing.\n\nThe consumer cannot resolve an unidentified contact, so an id-less message is guaranteed to fail\ndownstream. Failing early and visibly beats an undeliverable queue.\n\n=== Ordering\n\n`messageGroupId` is the workspace id with dashes stripped, or the HubSpot contact id where per-contact\nordering matters (sequence `crm_event` steps). Two updates to the same contact cannot be applied out of\norder.\n\n== Call hand-off eligibility\n\nA call is handed to the CRM when all of the following hold:\n\n* `durationSec` is set and `>= 10`\n* `inVoicemail` is false\n* `callScreeningDetected` is false\n* `disposition` is in `HUBSPOT_WHITE_LISTED_DISPOSITIONS`: `Qualified_Lead`, `Amazon_Seller`,\n  `Callback_Requested`, `Wrong_Number`, `Do_Not_Call`, `Not_a_Seller`, `AI_Issue`\n* `CampaignTarget.isTestTarget` is not true\n\nNegative outcomes such as `Do_Not_Call` and `Wrong_Number` are deliberately whitelisted — the CRM needs\nthem in order to suppress future outreach.\n\n`HUBSPOT_BLOCKED_DISPOSITIONS` records the inverse set for reference: `AI_Screening`,\n`Voicemail_Detected`, `dial_busy`, `dial_no_answer`, `invalid_destination`, `Gatekeeper`, `IVR`.\n\n[NOTE]\n====\nThe direct `hubSync.syncCall(...)` call remains in `lib/events/handlers/call-ended.ts`, commented out.\nThe live path publishes `Crm.DialerCallEnded` to SQS instead. `Call.hubspotSyncStatus` is therefore set by\nthe downstream consumer, not by this handler.\n====\n\n== Lifecycle stages that gate outreach\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Set | Values and effect\n\n| `SKIP_CALL_LIFECYCLE_STAGES` | `customer`, `marketingqualifiedlead` — pre-validation skips the target with `lifecycle_stage`\n| `HUBSPOT_WARM_LEAD_LIFECYCLE_STAGES` | `salesqualifiedlead`, `opportunity`, `closedwon`, `closedlost` — defined for the `warm_lead` skip reason, currently unreferenced by the dial path\n|===\n\n== Inbound webhooks\n\n`POST /api/webhooks/hubspot`:\n\n. Read the raw body — the signature is computed over the exact bytes.\n. Validate the HubSpot HMAC or legacy signature with `HUBSPOT_WEBHOOK_CLIENT_SECRET`. Unlike the Resend\n  route, this one *rejects* when the secret is unset.\n. Normalise the payload.\n. Publish as `Crm.HubSpotWebhook`.\n\nThe application does not act on HubSpot notifications itself; forwarding them keeps CRM reconciliation in\none place.\n\n== Direct API calls\n\nTwo paths still call HubSpot inline, both best-effort:\n\n* the sequence `email` step logs an email engagement (`hubActivity.logEmailActivity`);\n* the `send_email` MCP tool logs an engagement when `hubspot_contact_id` is supplied.\n\nBoth are wrapped so a failure is warned and the primary action still succeeds.\n\nA third path is fail-closed: `reengagementDailyListCron` `pre-list` uses\n`HubSpotListsClient` (`lib/plugins/hubspot/`) to find-or-create today's MANUAL\ncontact segments via `GET`/`POST` `/crm/lists/2026-03` using\n`HUBSPOT_PRIVATE_APP_TOKEN`. Names are\n`Outreach-Re-Engagement-{YYYY-MM-DD}-{First}-{Last}`. Existence must be known\nbefore pick fan-out, so this does not go through SQS. The private app needs\n`crm.lists.read` and `crm.lists.write` (or the `crm.segments.*` equivalents).\nA missing token, HubSpot error, or two active reps that slug to the same name\nfails the step; picks are not enqueued. Segment ids are not stored on `SalesRep`.\nEach pick event carries `{ salesRepId, listId }`. After insert, assigned daily-list\nrows get `hubspotListId` and those contacts are added with\n`PUT /crm/lists/2026-03/{listId}/memberships/add` (batches of 100). Unassigned\ncontacts are skipped.\n\nA separate 07:30 America/New_York run, `reengagementNeverCalledDailyList`,\ncreates one shared MANUAL list named `Outreach-Never-Called-{YYYY-MM-DD}`.\nIt adds up to 1,000 contacts who already appear on a re-engagement daily list\nand have no human-rep call in `hubspot4.v_calls_latest` (a call counts when\n`ownerid` is present and is not the AI owner). Memberships use the same\nbatches of 100. This list is not split by sales rep and does not write\n`reengagement_daily_list_entries`.\n\n== Unsubscribe compliance\n\n`Contact.Unsubscribe` carries `legal_basis` = `LEGITIMATE_INTEREST_OTHER`\n(`CRM_UNSUBSCRIBE_LEGAL_BASIS`) and a human-readable\n`CRM_UNSUBSCRIBE_LEGAL_BASIS_EXPLANATION`, so HubSpot's subscription record has an auditable reason.\n`HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID` identifies the subscription being changed.\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:telephony.adoc","title":"Carriers, MessagingApp, Nooks and JustCall","description":"Number acquisition over Twilio and Plivo, the MessagingApp microservice, Nooks ingestion and the JustCall legacy.","url":"https://docx.payability.com/dialer-v2/integrations/telephony.html","navOrder":66,"source":"= Carriers, MessagingApp, Nooks and JustCall\n:description: Number acquisition over Twilio and Plivo, the MessagingApp microservice, Nooks ingestion and the JustCall legacy.\n\n== Carrier registry\n\nAll carrier settings live in one place, `EXTERNAL_PROVIDER_CONFIGS` in `lib/constants.ts`:\n\n[source,typescript]\n----\nexport interface ExternalProviderConfig {\n  label: string          // shown in the UI\n  source: string         // MessagingApp `source` value on purchase/release\n  trunkSid: string       // SIP trunk identifier\n  terminationUrl: string // Retell SIP termination URL used on import\n}\n\nEXTERNAL_PROVIDER_CONFIGS = {\n  twilio: { label: 'Twilio', source: 'twilio', trunkSid: NEXT_TWILIO_TRUNK_SID, terminationUrl: NEXT_TWILIO_TERMINATION_URL },\n  plivo:  { label: 'Plivo',  source: 'plivo',  trunkSid: NEXT_PLIVO_TRUNK_SID,  terminationUrl: NEXT_PLIVO_TERMINATION_URL },\n}\n----\n\n`EXTERNAL_PROVIDER_KEYS` orders them for dropdowns; `DEFAULT_EXTERNAL_PROVIDER` is `twilio`. Individual\nexports (`TWILIO_TRUNK_SID`, `PLIVO_TERMINATION_URL`, …) exist for backward compatibility.\n\nAdding a carrier is a single new entry — `PhoneNumberCallingService`, `MessagingAppPhoneService`, the\npurchase route and the Phone Numbers page all read the registry dynamically.\n\n[source,bash]\n----\nNEXT_TWILIO_TRUNK_SID=\nNEXT_TWILIO_TERMINATION_URL=\nNEXT_PLIVO_TRUNK_SID=\nNEXT_PLIVO_TERMINATION_URL=   # defaults to 14928144285976266.zt.plivo.com\n----\n\n== MessagingApp\n\nAn internal Payability microservice that owns carrier accounts. The application never calls Twilio or\nPlivo directly — it asks MessagingApp to buy and release numbers, then imports them into Retell over SIP.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Concern | File\n\n| Base service with retry helper | `lib/phone-service/base.ts`\n| MessagingApp adapter | `lib/phone-service/messaging-app.ts`\n| Types | `lib/phone-service/types.ts`\n|===\n\n[source,bash]\n----\nNEXT_MESSAGING_APP_API_URL=\nNEXT_MESSAGING_APP_API_KEY=\nPHONE_NUMBER_DEFAULT_COUNTRY=US\n----\n\n`MessagingAppPhoneService` extends `BasePhoneNumberService`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Method | Purpose\n\n| `purchasePhoneNumber(...)` | Buy a number for a state or area code from the given provider\n| `releasePhoneNumber(phoneNumber, externalProvider)` | Return it to the carrier\n| `getPhoneNumberInfo(phoneNumber)` | Carrier metadata, falling back to deriving it from the number itself\n|===\n\nBoth write paths go through `withRetry`, because a transient failure while buying or releasing a number\nwould otherwise leak carrier spend.\n\n`getPhoneNumberInfo`'s fallback to number-derived data matters for the dialer: state extraction must\nwork even when the metadata service is unavailable, or the selection algorithm has nothing to match on.\n\n=== Acquisition flow\n\n. `POST /api/phone-numbers` with a state or area code and a provider.\n. MessagingApp buys the number and returns its `phoneNumberSid`.\n. `RetellClient.importNumber(phoneNumber, terminationUrl, outboundAgentId)` imports it over the\n  provider's SIP trunk.\n. The `PhoneNumbers` row is written with both `phoneNumberSid` and `retellPhoneNumberId`.\n\nRelease is the reverse: unregister at Retell, release at MessagingApp, then mark `isReleased` with\n`releasedAt`. The row is kept so historical calls keep a resolvable `phoneNumberId`.\n\nAutomatic purchasing during dialing is currently disabled — see\nxref:workflows:utility.adoc[].\n\n== Nooks\n\nhttps://www.nooks.ai[Nooks] is the dialer human reps use. Only inbound webhooks are involved; the\napplication does not call the Nooks API for call data.\n\n[source,bash]\n----\nNOOKS_WEBHOOK_SECRET=   # optional; enables Bearer / X-Nooks-Signature verification\nNOOKS_API_KEY=          # present in the environment; not used by the webhook path\n----\n\nCalls arrive at `POST /api/webhooks/nooks` as `call.logged` and are stored with\n`channelType = \"nooks_call\"`, `campaignId = null` and `targetId = null`. Attribution is by\n`SalesRep.nooksUserId` with an email fallback. Two disposition-gated flows follow: AI analysis, and a\n`Crm.DealUpdate` closed-lost publish. See xref:events:nooks.adoc[].\n\n`FollowUpTask.nooksTaskId` and `nooksSequenceStateId` exist for tasks and sequence enrollments mirrored\ninto Nooks, and `SalesRep.nooksUserId` is what makes that assignment possible.\n\n== Five9\n\n`POST /api/webhooks/five9` accepts a payload, stores it as a `WebhookEvent`, and returns. There is no\nhandler and no dispatch.\n\n[WARNING]\n====\nThe route writes with `source = \"Nooks\"` and `eventType = \"five_9\"`, generating a random `eventId` and\n`providerCallId`. Its rows are therefore not idempotent on replay and share the `Nooks` source with\ngenuine Nooks events. Filter on `eventType` when querying.\n====\n\n== JustCall (legacy)\n\nJustCall predates the AI dialer and survives in three places:\n\n* `Profile.justcallUserId`, `isAvailable`, `activeCalls`, `maxConcurrentCalls` — the human agent\n  availability model, and `GET|PATCH /api/agents`.\n* `Call.justCallId` on historical rows.\n* The `handle_justcall_call_initiated()` database trigger on `trg_webhookevent_call_initiated`, which\n  watches for JustCall `call.initiated` rows in `WebhookEvent`, extracts the JustCall user id, and marks\n  the matching profile unavailable while incrementing its active-call count.\n\n`JustCallEventTypeEnum` in `lib/enums.ts` enumerates the event names\n(`justcall_call.initiated`, `.answered`, `.incoming`, `.completed`, `.updated`).\n\nAvailability tracking happens entirely in the trigger, with no application handler. New work should not\nextend this path — current rep identity lives on `SalesRep`.\n"},{"component":"dialer-v2","module":"integrations","path":"integrations:platform-services.adoc","title":"Platform services","description":"Supabase, the Vercel AI Gateway, BigQuery, AWS SQS, Redis and Sentry.","url":"https://docx.payability.com/dialer-v2/integrations/platform-services.html","navOrder":67,"source":"= Platform services\n:description: Supabase, the Vercel AI Gateway, BigQuery, AWS SQS, Redis and Sentry.\n\n== Supabase\n\nFour capabilities, four clients.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Client | File | Use\n\n| Server | `lib/supabase/server-client.ts` | Server components and route handlers; reads session cookies\n| Browser | `lib/supabase/browser-client.ts` | Client components\n| Route handler | `lib/supabase/route-handler-client.ts` | Sign-in and sign-out, where cookies must be *written* on the response\n| Admin | `lib/supabase/admin-client.ts` | Service-role operations; never reachable from the client\n|===\n\n`lib/supabase/anon-env.ts` resolves the public URL and anon key, returning `null` when they are absent so\nmiddleware can allow requests through rather than crashing on a misconfigured environment.\n\n[source,bash]\n----\nNEXT_PUBLIC_SUPABASE_URL=\nNEXT_PUBLIC_SUPABASE_ANON_KEY=\nSUPABASE_SERVICE_ROLE_KEY=\nDATABASE_URL=      # pooled connection, used at runtime\nDIRECT_URL=        # direct connection, used by migrations\nSUPABASE_GOOGLE_CLIENT_ID=\nSUPABASE_GOOGLE_CLIENT_SECRET=\n----\n\n=== PostgreSQL\n\nAccessed exclusively through Prisma (`lib/prisma.ts`), and only from `lib/repositories`. Runtime traffic\ngoes through the pooler (`DATABASE_URL`); migrations use `DIRECT_URL`.\n`@prisma/extension-accelerate` is available for query caching where a workload justifies it.\n\nObjects managed in Supabase rather than in this repository:\n\n* the `get_and_lease_due_targets` RPC, which atomically leases due call targets;\n* reporting views — `vm_main_campaign_reports`, `main_required_summaries`,\n  `main_sankey_chart_view`, `vm_main_daily_agent_performance_report`, `phone_number_pickup_rate`,\n  `v_email_campaign_daily_engagement`;\n* the `call_daily_stats` and `agent_daily_performance_stats` rollup tables with their refresh\n  functions and `pg_cron` jobs, scripted in `lib/supabase/sql/`;\n* the `handle_justcall_call_initiated()` trigger function;\n* `pg_cron` jobs that call the broadcast endpoints.\n\nMost views prefixed `vm_` are materialised — check their refresh schedule before treating stale numbers\nas a bug. `vm_main_campaign_reports` and `vm_main_daily_agent_performance_report` are the exceptions:\nthey keep the prefix for compatibility but read from incrementally maintained rollup tables. See\nxref:domain:reporting.adoc#rollups[Daily report rollups].\n\n=== Auth\n\nSupabase Auth owns identity and sessions. Sessions are HTTP-only cookies, refreshed automatically.\nSignup, sign-in and sign-out go through the app's own API so the *server* sets the cookies. See\nxref:security:authentication.adoc[].\n\n`prisma/sql/auth_hook_before_user_created_payability.sql` is a Supabase auth hook enforcing the\nPayability email domain at the point of user creation — a second line of defence behind\n`isAllowedPayabilityEmail()` in middleware.\n\n=== Realtime\n\n`RetellCallEventHandler.broadcastEvent(event, campaignId, payload)` posts to\n`<SUPABASE_URL>/realtime/v1/api/broadcast` on the topic `campaign-<campaignId>`, letting the dashboard\nreact to call events live. Skipped with a warning when the URL or key is missing.\n\nClient-side subscription lives in `lib/hooks/use-realtime.ts`.\n\n=== Edge Functions\n\n`lib/supabase/functions/` holds Deno functions deployed to Supabase, not to Vercel:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Function | Purpose\n\n| `event-logger` | Webhook receipt at the edge\n| `campaign-scheduler` | Broadcasts a `no_available_target` Realtime event for a campaign\n| `hello-world` | Scaffolding\n|===\n\n[#ai-gateway]\n== Vercel AI Gateway\n\nOne endpoint fronting several model providers, so switching models is a string change rather than a new\nSDK.\n\n[source,bash]\n----\nAI_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1   # default\nAI_GATEWAY_API_KEY=\n----\n\nConfig:: `lib/ai/config.ts`\nBase class:: `lib/ai/base-ai-service.ts` — `BaseAIService`, using `@ai-sdk/openai-compatible`\n\nServices extending it:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Service | Purpose\n\n| `CallAnalysisService` | Grade a completed call\n| `CoverageRecommendationService` | Suggest area codes to acquire\n| `EmailVariationService` | Rephrase an email variation\n| `PromptSuggestionService` | Propose a prompt revision from observed issues\n| `TestCaseRunnerService` | Predict outcomes for regression test cases\n|===\n\n=== Model selection\n\n`AI_GATEWAY_MODELS` lists selectable models with a provider tag:\n\n* OpenAI — `openai/gpt-5`, `gpt-5-mini`, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`\n* Anthropic — `anthropic/claude-sonnet-5`, `claude-sonnet-4.6`, `claude-sonnet-4`,\n  `claude-haiku-4.5`, `claude-3-5-sonnet`, `claude-3-5-haiku`\n* xAI — `xai/grok-4.5`\n* Google — `google/gemini-3.5-flash`\n\n`getModelSlug(modelId, llmModelType)` prefers an explicit `AiAgent.modelId`, falling back to the legacy\n`llmModelType` default (`gpt` → `openai/gpt-4o`, `claude` → `anthropic/claude-sonnet-4`).\n`getModelLabel(slug)` and `getProviderFromSlug(slug)` support the UI.\n\nThe legacy `gpt`/`claude` enum predates per-model selection; new agents should always set `modelId`.\n\n`@payability/pyb-llm` provides shared LLM helpers, including a HubSpot-aware agent used by the dial\npipeline.\n\n== Google BigQuery\n\nA read-only mirror of CRM, call and engagement data, queried by sequence data gates.\n\nClient:: `lib/bigquery/client.ts`\n\n[source,bash]\n----\nBIGQUERY_PROJECT_ID=bigqueryexport-183608   # default in lib/constants.ts\nBIGQUERY_PRIVATE_KEY_ID=\nBIGQUERY_PRIVATE_KEY=       # PEM with escaped \\n; restored to real newlines at runtime\nBIGQUERY_CLIENT_ID=\n----\n\nThe service-account email is hardcoded as\n`vercel-bigquery-agent@bigqueryexport-183608.iam.gserviceaccount.com`. The client is lazily created and\nmemoised.\n\n=== Injection safety\n\nView, field and identifier *names* are SQL identifiers and cannot be passed as query parameters, so they\nare validated against a strict allowlist before interpolation. The identifier *value* is always bound as a\nparameter.\n\n`queryGateConditions({ view, identifierColumn, identifierValue, matchMode, conditions })` evaluates\nconditions against a single contact-scoped row. Operators: `is_not_null`, `is_null`, `equals`,\n`not_equals`, `gt`, `lt`, `is_in`, `is_not_in`. `matchMode` is `all` (AND) or `any` (OR).\n\nThe gate view must expose a `hubspotcontactid` column, and a contact without a `hubspotContactId` fails\nthe gate closed. See xref:domain:sequences.adoc#data-gates[Data gates].\n\n`queryUnsyncedHubspotContacts(limit)` runs a fixed SELECT against `hubspot4.v_contacts_latest`,\nexcluding HubSpot ids already present in `outreach_app.contacts`. `LIMIT` is bound (`@limit`,\ndefault and ceiling 1000). SQL is documented in `lib/bigquery/hubspot4/unsynced-contacts.sql`.\nUsed by `contacts/hubspot-import`.\n\n`queryHubspotCallsSince(since, limit)` runs a fixed SELECT against `hubspot4.v_calls_latest` for\nrows with `updated > TIMESTAMP(@since)`. `@since` is a bound STRING (RFC 3339 Configs value) and\n`@limit` is INT64 (default and ceiling 1000). SQL is documented in `lib/bigquery/hubspot4/calls-since.sql`.\nUsed by `calls/hubspot-import`. The watermark lives in Configs key `hubspot_call_sync_cursor`\n(ISO datetime) on the `HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID` workspace; a missing or invalid\ncursor skips the run.\n\n`querySuppliersUpdatedSince(cursor, limit)` runs a fixed keyset SELECT against\n`PayabilitySheets.v_supplier_summary_extended` for rows after `(updated, supplier_key)`.\n`@sinceDate` is a bound STRING (`YYYY-MM-DD`), `@sinceKey` is STRING, and `@limit` is INT64\n(default and ceiling 1000). SQL is documented in\n`lib/bigquery/payability-sheets/v-supplier-summary-since.sql`. Used by\n`suppliers/payability-import`. The watermark lives in Configs key `supplier_sync_cursor`\n(`{ \"updated\": \"YYYY-MM-DD\", \"supplier_key\": \"<uuid>\" }`) on the\n`HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID` workspace; a missing cursor starts at `1970-01-01`\nwith an empty key.\n\n`querySuppliersByKeys(keys)` re-reads current view rows for known `supplier_key`s\n(`IN UNNEST(@keys)`, capped at 1000). Used after the incremental batch so status from\nother tables in the view still overwrites local rows and stamps `lastSyncedAt`. SQL is\ndocumented in `lib/bigquery/payability-sheets/v-supplier-summary-by-keys.sql`.\n\n`scripts/test-bigquery.ts` is a connectivity check.\n\n== AWS SQS\n\nFire-and-forget hand-off to downstream Payability services.\n\nClient:: `lib/plugins/sqs-client.ts` (`@aws-sdk/client-sqs`)\n\n[source,bash]\n----\nAWS_REGION=\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nCRM_QUEUE_URL=\nINCOMING_EMAIL_SQS_QUEUE_URL=   # defaults to the morpheus-sam-email-agent queue\nEMAIL_VARIATION_QUEUE_URL=\n----\n\nPublishers return a boolean rather than throwing, so a missing queue URL degrades to \"not published\"\ninstead of failing the caller. `messageGroupId` provides FIFO ordering per workspace or per contact. See\nxref:events:crm-sqs.adoc[].\n\n== Redis\n\nHolds the per-campaign in-flight call counter — the one piece of shared mutable state outside PostgreSQL.\n\nClient:: `lib/plugins/redis-client.ts`\nCounter:: `lib/redis/call-target-counter.ts` — `getCallTargetRuns`, `incrementCallTargetRuns`,\n`decrementCallTargetRuns`\n\n[source,bash]\n----\nREDIS_URL=\n----\n\nThe counter exists because a call is in-flight from the moment an Inngest event is sent until the\nprovider webhook lands, which the database cannot see cheaply. The broadcaster reads it, `callTarget`\ndecrements on every exit path, and `callEndedAnalysis` increments before a double tap. See\nxref:architecture:async-runtime.adoc[].\n\n== Sentry\n\nError and performance monitoring across all three runtimes.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| File | Runtime\n\n| `sentry.client.config.ts` | Browser\n| `sentry.server.config.ts` | Node\n| `sentry.edge.config.ts` | Edge\n| `instrumentation.ts`, `instrumentation-client.ts` | Next.js instrumentation hooks\n| `lib/sentry.ts` | Shared helpers\n|===\n\n[source,bash]\n----\nNEXT_PUBLIC_SENTRY_DSN=\nNEXT_PUBLIC_SENTRY_ENVIRONMENT=\n----\n\n`.env.sentry-build-plugin` holds the build-time upload token.\n\n`resolveSubscription()` sets the Sentry user and a `subscriptionId` tag, so any error later in the request\nis already attributed to a tenant. `@inngest/middleware-sentry` attaches workflow context to step\nfailures.\n\n`GET /api/sentry-example-api` and `/sentry-example-page` verify the wiring.\n"},{"component":"dialer-v2","module":"security","path":"security:index.adoc","title":"Security","description":"The security model — who can call what, and how each boundary is enforced.","url":"https://docx.payability.com/dialer-v2/security/index.html","navOrder":68,"source":"= Security\n:description: The security model — who can call what, and how each boundary is enforced.\n\n== Boundaries\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Boundary | Enforced by | Page\n\n| Is this request from the company network? | Vercel WAF rules in `vercel.firewall.json`: VPN exit IPs only, except machine routes | xref:ops:deployment-and-jobs.adoc#_firewall[]\n| Is this a real user? | Supabase Auth session cookies, checked by middleware and every route | xref:authentication.adoc[]\n| Is this user allowed here at all? | `isAllowedPayabilityEmail()` in middleware, plus a Supabase auth hook | xref:authentication.adoc[]\n| Which workspace's data? | `resolveSubscription()` verifying `SubscriptionUser` membership | xref:authorization.adoc[]\n| Can they do this to the workspace? | Role checks in the members routes | xref:authorization.adoc[]\n| Is this machine caller legitimate? | `verifyInternalSecret()`, `MCP_API_SECRET`, the Inngest signing key | xref:secrets-and-webhooks.adoc[]\n| Did this webhook really come from the provider? | Svix, HubSpot HMAC, Nooks secret | xref:secrets-and-webhooks.adoc[]\n|===\n\n== Non-negotiable rules\n\nEvery tenant-scoped query filters on `subscriptionId`::\nIsolation is enforced in application code at the repository layer, not by PostgreSQL row-level security.\nThere is no database-level backstop, so a missing filter is a data leak. See\nxref:architecture:multi-tenancy.adoc[].\n\nValidate every input with Zod::\nRoute handlers parse bodies and query strings before use. Client-side types are inferred from the same\nschema, so validation and typing cannot drift.\n\nNever build raw SQL from user input::\nPrisma parameterises everything. The places that emit raw SQL —\n`fetchAndLockPendingTargets`, `incrementSendingDomainDailyCount` and the BigQuery gate builder — use\nbound parameters for values, and the\nBigQuery builder validates identifier *names* against a strict allowlist because identifiers cannot be\nparameterised.\n\nThe service-role key is server-only::\n`lib/supabase/admin-client.ts` is never imported from a client component. Only `NEXT_PUBLIC_*` variables\nreach the browser.\n\nMachine secrets fail closed::\n`verifyInternalSecret()` rejects every request when `INTERNAL_API_SECRET` is unset, rather than\nallowing it through. Scheduled jobs are Inngest cron functions behind the signed `/api/inngest`\nendpoint, so there are no cron routes to protect.\n\nFirewall rules change only through `vercel.firewall.json`::\nChanges are reviewed in a pull request and applied from `main` with `pnpm firewall:apply`. A route that machines call from outside the VPN must be added to\nthe *Allow machine routes* rule *and* enforce its own auth, because the bypass makes it reachable from\nanywhere.\n\nSanitise user-generated HTML before rendering it::\nReact escapes JSX by default, but email bodies are rendered as HTML. Anything user-authored that reaches\n`dangerouslySetInnerHTML` must be sanitised first.\n\n== Known gaps\n\nRecorded so they are decided rather than forgotten.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Gap | Impact | Mitigation today\n\n| `POST /api/webhooks/event-logger` has no signature verification\n| Anyone who learns the URL can forge Retell call events. The firewall bypasses `/api/webhooks/` for all IPs, so the VPN rule does not protect it\n| The URL is treated as a secret; correlation requires valid `campaignId` and `campaignTargetId` metadata, and `Call.providerCallId` is unique\n\n| `/api/webhooks/five9` and `/api/webhooks/nooks` do not verify the caller (the Nooks check is commented out)\n| Forged events from any IP, because the firewall bypasses `/api/webhooks/`\n| None yet; add shared-secret or signature checks\n\n| `RESEND_WEBHOOK_SECRET` unset skips Svix verification\n| Forged email events in an environment where it is not configured\n| Set it in every deployed environment; the skip exists for local development\n\n| `POST /api/webhooks/five9` generates a random `eventId`\n| Replays are not idempotent, and rows are stored under `source = \"Nooks\"`\n| The payload is stored only and never dispatched; filter on `eventType`\n\n| No rate limiting on API routes\n| Brute-force and abuse surface on auth endpoints\n| Supabase Auth applies its own throttling on sign-in\n\n| `Configs.key` is globally unique, not per workspace\n| Two workspaces cannot hold different values for the same key\n| Treat `Configs` as effectively global until the constraint changes\n\n| No PostgreSQL row-level security\n| A repository method missing its `subscriptionId` filter leaks across tenants\n| Review checklist in xref:architecture:multi-tenancy.adoc[]\n|===\n"},{"component":"dialer-v2","module":"security","path":"security:authentication.adoc","title":"Authentication","description":"Supabase Auth, the API-based session flow, middleware protection and the email-domain gate.","url":"https://docx.payability.com/dialer-v2/security/authentication.html","navOrder":69,"source":"= Authentication\n:description: Supabase Auth, the API-based session flow, middleware protection and the email-domain gate.\n\nIdentity is Supabase Auth. Sessions are HTTP-only cookies, refreshed automatically.\n\n== Why the API, not the browser client\n\nSignup, sign-in and sign-out go through the application's own endpoints rather than\n`supabase.auth.*` on the client, so the *server* writes the session cookies through the route-handler\nSupabase client. The register endpoint also has to create a `Profile`, a `Subscription` and a\n`SubscriptionUser` in the same operation — work the browser client cannot do.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Endpoint | Effect\n\n| `POST /api/auth/register` | Creates the Supabase user, then `Profile` + `Subscription` + `SubscriptionUser` (role `owner`). Checks password strength (`lib/auth/password-strength.ts`) and the email domain\n| `POST /api/auth/signin` | Signs in and sets the session cookies on the response\n| `POST /api/auth/signout` | Clears the session and the `active_subscription_id` cookie\n|===\n\nCall them with `credentials: \"same-origin\"`. After sign-out, redirect to `/login` and call\n`router.refresh()` so server components re-render without the session.\n\nCalling `supabase.auth.signInWithPassword` directly still works, but skips the profile/workspace creation\nand the cookie clearing, so prefer the endpoints.\n\n== Flow\n\n[mermaid]\n....\nsequenceDiagram\n  participant U as User\n  participant C as Client\n  participant API as /api/auth/*\n  participant SA as Supabase Auth\n  participant MW as middleware.ts\n\n  U->>C: submit credentials\n  C->>API: POST /api/auth/signin\n  API->>SA: signInWithPassword\n  SA-->>API: session\n  API-->>C: Set-Cookie (HTTP-only)\n  C->>MW: GET /campaigns\n  MW->>SA: getSession + getUser\n  MW->>MW: isAllowedPayabilityEmail(email)?\n  MW-->>C: allow, or redirect to /login\n....\n\n== Middleware\n\n`middleware.ts` runs on every non-excluded path.\n\n. *Bypass* — `/.well-known` and `/well-known` (workflow runtime callbacks) return immediately, as do\n  `/icons/*` and `/assets/*`. Public assets must bypass auth or the login page cannot load its own logos.\n. *Resolve config* — `resolveSupabaseAnonEnv()`. When credentials are absent the request is allowed\n  through, because auth cannot be enforced and failing every request would make a misconfigured\n  environment unreachable.\n. *Domain gate* — when a session exists, the user's email is checked against\n  `isAllowedPayabilityEmail()`. A non-Payability address is signed out and redirected to\n  `/login?error=forbidden_domain`.\n. *Protect* — no session, and the path is not `/login`, `/signup`, `/auth/callback`, `/docs` or `/api`\n  → redirect to `/login?redirectedFrom=<path>`.\n. *Bounce* — a session on `/login` or `/signup` → redirect to `/`.\n\n=== Matcher\n\n[source,typescript]\n----\nmatcher: [\n  \"/((?!_next/static|_next/image|favicon.ico|icons|assets|images|fonts|supabase/functions|api/webhooks|\\\\.well-known|well-known).*)\",\n]\n----\n\n`api/webhooks` is excluded so provider callbacks are never redirected. `/api` is excluded from the\nredirect rule inside the middleware too — API routes return `401` rather than a redirect, because a\nfetch cannot follow one usefully.\n\n== The email-domain gate\n\n`lib/auth/allowed-email-domain.ts`:\n\n[source,typescript]\n----\nexport function isAllowedPayabilityEmail(email: string): boolean {\n  const normalized = email.trim().toLowerCase()\n  const at = normalized.lastIndexOf(\"@\")\n  if (at <= 0 || at === normalized.length - 1) return false\n  const domain = normalized.slice(at + 1)\n  return domain === \"payability.com\" || domain.endsWith(\".payability.com\")\n}\n----\n\nEnforced in two places:\n\n* *Middleware*, on every request with a session — so a user whose access is revoked is signed out on\n  their next page load, not at their next login.\n* *A Supabase auth hook* — `prisma/sql/auth_hook_before_user_created_payability.sql` blocks creation of a\n  disallowed user at the source, which also covers OAuth sign-ups that never touch the register endpoint.\n\n`PAYABILITY_EMAIL_FORBIDDEN_MESSAGE` is the user-facing string.\n\n== Server-side checks\n\nTenant-scoped route:\n\n[source,typescript]\n----\nimport { resolveSubscription } from \"@/lib/auth/subscription\"\n\nconst result = await resolveSubscription()\nif (!result.success) {\n  return NextResponse.json({ error: result.error }, { status: result.status })\n}\nconst { subscriptionId } = result.context\n----\n\nAuth-only route:\n\n[source,typescript]\n----\nimport { getSupabaseServerClient } from \"@/lib/supabase/server-client\"\n\nconst supabase = await getSupabaseServerClient()\nconst { data: { user } } = await supabase.auth.getUser()\nif (!user) return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 })\n----\n\nPrefer `resolveSubscription()` — it authenticates *and* resolves the tenant in one call, and stamps\nSentry with the user and workspace.\n\n== Client-side\n\n[source,typescript]\n----\nimport { getSupabaseBrowserClient } from \"@/lib/supabase/browser-client\"\n\nconst supabase = getSupabaseBrowserClient()\nconst { data: { user } } = await supabase.auth.getUser()\n----\n\nMost components should not need this — `GET /api/me` via the `useMe()` hook already provides the user,\nprofile and workspace list.\n\n== Google OAuth\n\n`SUPABASE_GOOGLE_CLIENT_ID` and `SUPABASE_GOOGLE_CLIENT_SECRET` configure Google sign-in, with\n`/auth/callback` handling the redirect. The domain gate applies to OAuth users identically — the auth hook\nblocks creation, and middleware signs out anyone who slips through.\n\n== Error responses\n\n[source,json]\n----\n{ \"error\": \"Unauthorized\" }   // 401 — no session\n{ \"error\": \"Forbidden: not a member of this subscription\" }   // 403\n{ \"error\": \"No profile found for user\" }   // 403\n{ \"error\": \"No subscription found for user\" }   // 403\n----\n"},{"component":"dialer-v2","module":"security","path":"security:authorization.adoc","title":"Authorization","description":"Workspace membership, roles and what each role may do.","url":"https://docx.payability.com/dialer-v2/security/authorization.html","navOrder":70,"source":"= Authorization\n:description: Workspace membership, roles and what each role may do.\n\nAuthentication establishes *who*. Authorization establishes *which workspace* and *what within it*.\n\n== Workspace membership\n\nEvery tenant-scoped request resolves a workspace through `resolveSubscription()`, which verifies a\n`SubscriptionUser` row for `(subscriptionId, profileId)`. Requesting a workspace you are not a member of\nreturns `403 Forbidden: not a member of this subscription`.\n\nResolution order:\n\n. `x-subscription-id` header — an explicit override, used by MCP and by internal callers\n. `active_subscription_id` cookie — the dashboard's current workspace\n. Default — the workspace the user *owns*, else their oldest membership\n\nDefaulting to the owned workspace matters for a user who has been added to several: their own is almost\nalways the one they mean.\n\nThe cookie is written only by `POST /api/subscriptions/active` and cleared only by\n`POST /api/auth/signout`. Client code never writes it.\n\n== Roles\n\n`SubscriptionUser.role` is `owner`, `admin` or `member`.\n\n[cols=\"2,1,1,1\",options=\"header\"]\n|===\n| Action | owner | admin | member\n\n| Read workspace data | ✓ | ✓ | ✓\n| Create and modify campaigns, targets, agents, templates | ✓ | ✓ | ✓\n| List members | ✓ | ✓ | ✓\n| Add a member | ✓ | ✓ | —\n| Change a member's role | ✓ | ✓ (not to or from `owner`) | —\n| Remove a member | ✓ | ✓ (not an owner) | —\n| Assign the `owner` role | ✓ | — | —\n| Remove an owner | ✓ | — | —\n|===\n\nRoles gate *workspace administration*, not domain operations. A `member` can run campaigns and edit\nagents; what they cannot do is change who else has access.\n\nEnforced in `app/api/subscriptions/members/route.ts` and\n`app/api/subscriptions/members/[membershipId]/route.ts`. The rule that only owners may touch ownership\nprevents an admin from escalating themselves.\n\nManaged from `/workspace`; hooks in `lib/hooks/use-workspace-members.ts` and\n`lib/hooks/use-subscriptions.ts`.\n\n== Scoping in queries\n\nIsolation is enforced in application code, not by row-level security. Every repository method that reads\ntenant data filters on `subscriptionId`.\n\nNullable-scope models need care:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Model | Why nullable, and what to do\n\n| `Call.subscriptionId` | A Nooks call may arrive before a workspace match. Use `lib/utils/call-subscription-scope.ts` to build the correct `where` fragment\n| `CampaignTarget.subscriptionId` | Legacy rows predate the column. Scope through `campaignId` as well\n| `AiAgent.subscriptionId` | Allows a shared agent across workspaces. Decide deliberately whether a query should include shared agents\n|===\n\nUniqueness constraints encode the boundary — `(subscriptionId, email)` on `Contact`, `TargetEmails` and\n`SalesRep`; `(subscriptionId, name)` on `Domain`; `(subscriptionId, externalId)` on `Activity` and\n`Emails`. A new unique constraint should include `subscriptionId` unless the value is genuinely global,\nas `TargetPhoneNumbers.phoneNumber` and `PhoneNumbers.phoneNumber` are.\n\n== Cross-workspace guards in domain code\n\nSome checks catch data that has drifted across the boundary:\n\n* The campaign broadcaster refuses a target whose `TargetPhoneNumbers` row belongs to another workspace,\n  marking it `skipped` with `lastError = \"Target phone registry subscription mismatch\"`.\n* The `initiate_call` MCP tool verifies the target's campaign belongs to the caller's workspace before\n  dialing.\n* Email campaign routes verify `source.subscriptionId === subscriptionId` before duplicating.\n\n== Review checklist\n\n* Did the route call `resolveSubscription()` and handle `success: false`?\n* Does every repository `where` include `subscriptionId`?\n* For nullable-scope models, is the null case handled deliberately?\n* Does a new unique constraint include `subscriptionId`?\n* Does a new member-administration path check the role, including the owner-only rules?\n"},{"component":"dialer-v2","module":"security","path":"security:secrets-and-webhooks.adoc","title":"Secrets and webhook verification","description":"Machine-to-machine secrets, provider signature verification and secret handling.","url":"https://docx.payability.com/dialer-v2/security/secrets-and-webhooks.html","navOrder":71,"source":"= Secrets and webhook verification\n:description: Machine-to-machine secrets, provider signature verification and secret handling.\n\n== Machine-to-machine secrets\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Secret | Guards | Behaviour when unset\n\n| `INTERNAL_API_SECRET` | Broadcast endpoints and internal routes, via `verifyInternalSecret()` | *Fails closed* — every request rejected\n| `MCP_API_SECRET` | `POST /api/mcp` | `-32603 Server misconfiguration` (HTTP 500)\n| `INNGEST_SIGNING_KEY` | `/api/inngest` | Inngest refuses to invoke functions\n| `SUPABASE_SERVICE_ROLE_KEY` | Admin Supabase client | Admin operations fail\n|===\n\n=== `verifyInternalSecret`\n\n[source,typescript]\n----\nexport function verifyInternalSecret(request: Request): boolean {\n  if (!INTERNAL_API_SECRET) {\n    console.error(\"[auth] INTERNAL_API_SECRET is not configured\")\n    return false\n  }\n  const authHeader = request.headers.get(\"authorization\") ?? \"\"\n  const token = authHeader.startsWith(\"Bearer \") ? authHeader.slice(7) : \"\"\n  return token.length > 0 && token === INTERNAL_API_SECRET\n}\n----\n\nDeliberately generic: any internal endpoint — cron-triggered broadcasts, edge functions, other services —\ncan use it. Failing closed on an unset secret means a misconfigured environment rejects requests rather\nthan silently exposing them.\n\nCallers: `+POST /api/campaigns/{id}/broadcast+`, `+POST /api/email-campaigns/{id}/broadcast+`.\n\n=== MCP\n\n`POST /api/mcp` requires two headers:\n\n[source]\n----\nAuthorization: Bearer <MCP_API_SECRET>\nx-subscription-id: <subscriptionId>\n----\n\nA single shared secret, deliberately not a Supabase session — MCP callers are machines and take the\ntenant explicitly. Every tool scopes its database access to `context.subscriptionId`, so the header is the\nonly tenancy input and cannot be bypassed. See xref:api:mcp.adoc[].\n\n== Provider signature verification\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Provider | Mechanism | When the secret is unset\n\n| Resend | Svix, over the raw body, using `RESEND_WEBHOOK_SECRET` | Verification *skipped* (development mode)\n| HubSpot | HMAC / legacy signature, `HUBSPOT_WEBHOOK_CLIENT_SECRET` | Request *rejected*\n| Nooks | `Authorization: Bearer` or `X-Nooks-Signature`, `NOOKS_WEBHOOK_SECRET` | Verification *skipped*\n| Telnyx | Ed25519 over the raw body, `TELNYX_PUBLIC_KEY` | Request *rejected* (`503`)\n| ElevenLabs | HMAC-SHA256 over `{t}.{rawBody}`, `ELEVENLABS_WEBHOOK_SECRET` | Request *rejected* (`503`)\n| Retell | none | —\n| Five9 | none | —\n|===\n\nEvery verifying route reads the body as raw text *first*, because signatures are computed over the exact\nbytes — parsing and re-serialising would change them.\n\nHubSpot, Telnyx and ElevenLabs reject on a missing secret. Resend and Nooks skip. HubSpot publishes onto\nthe CRM write path; Telnyx and ElevenLabs are public machine routes, so an unset `TELNYX_PUBLIC_KEY` or\n`ELEVENLABS_WEBHOOK_SECRET` must not accept traffic.\n\n=== Idempotency as a second line of defence\n\nVerification is not the only protection. `WebhookEvent` is unique on `(source, eventId)`,\n`Call.providerCallId` is unique, `Activity` and `Emails` are unique on `(subscriptionId, externalId)`, and\nthe `emailSend` workflow refuses a target that has left `pending`. A replayed event — forged or genuine —\nis absorbed rather than duplicated.\n\nThe exception is `POST /api/webhooks/five9`, which generates a random `eventId` and is therefore not\nidempotent on replay.\n\n== Secret handling\n\nServer-only::\nEverything except `NEXT_PUBLIC_*` is server-only. Never import `lib/supabase/admin-client.ts` from a\nclient component, and never inline a secret into a component prop.\n\nClient-exposed::\nOnly `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `NEXT_PUBLIC_SENTRY_DSN`,\n`NEXT_PUBLIC_SENTRY_ENVIRONMENT` and `NEXT_PUBLIC_APP_URL`. The anon key is safe to expose; the\nservice-role key is not.\n\nNot committed::\n`.env` and `.env.sentry-build-plugin` are git-ignored. Deployed values live in Vercel's environment\nconfiguration.\n\nHardcoded values that are not secrets::\n`RETELL_SMS_AGENT_ID`, `HUBSPOT_PORTAL_ID`, `HUBSPOT_TASK_OWNER_ID`,\n`HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID`, the BigQuery service-account email and the default\n`INCOMING_EMAIL_SQS_QUEUE_URL`. They are identifiers, not credentials, but changing an environment means\nreviewing them.\n\n=== Rotation\n\n. Update the value in Vercel for the target environment.\n. Redeploy — `lib/constants.ts` reads `process.env` at module load, so a running instance keeps the old\n  value.\n. For `INTERNAL_API_SECRET`, update the `pg_cron` job definitions in Supabase in the same window;\n  broadcasts will `401` until both sides match.\n. For provider webhook secrets, update the provider's dashboard first, then the environment — a brief\n  window of skipped or failed verification is preferable to a window of dropped events.\n\n== Input validation\n\nEvery route parses its body and query string with Zod before use. Schemas live in\n`lib/schema/<feature>.ts` and are the source of the TypeScript types, so validation and typing cannot\ndrift.\n\nRaw SQL appears in exactly two places, both parameterised:\n\n* `fetchAndLockPendingTargets` — `Prisma.sql` with bound values.\n* `lib/bigquery/client.ts` — the identifier *value* is bound as a parameter; view and field *names*\n  cannot be parameterised in SQL, so they are validated against a strict allowlist before interpolation.\n\n== Rendering user content\n\nReact escapes JSX by default. The exception is email HTML, which is rendered as markup — anything\nuser-authored that reaches `dangerouslySetInnerHTML` must be sanitised first.\n"},{"component":"dialer-v2","module":"frontend","path":"frontend:index.adoc","title":"Frontend","description":"How the dashboard is built — rendering model, data flow and component organisation.","url":"https://docx.payability.com/dialer-v2/frontend/index.html","navOrder":72,"source":"= Frontend\n:description: How the dashboard is built — rendering model, data flow and component organisation.\n\nThe dashboard is a Next.js App Router application. Server components render the shell; client components\nown interactivity and all server state.\n\n== Rendering model\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Layer | Role\n\n| Server components | Layouts, page shells, static structure. The default — no `\"use client\"`\n| Client components | Anything with state, effects or event handlers. Marked `\"use client\"`\n| React Query | *All* server state. Client components fetch through hooks in `lib/hooks/`\n|===\n\nPages never query Prisma directly. Everything goes through the application's own HTTP API, so the\ndashboard and any external consumer see the same contract — and a schema change breaks loudly in one place\nrather than silently in two.\n\n== WorkspaceGate\n\nThe dashboard layout wraps its children in *WorkspaceGate*: nothing renders until `GET /api/me` has\nresolved and the user has at least one workspace with an active selection. A loading screen is shown until\nthen.\n\nWithout the gate, components would render against an unresolved workspace and issue queries that either\nfail or return another tenant's default. The gate makes \"the workspace is known\" an invariant for every\ncomponent below it.\n\nThe sidebar workspace switcher posts to `POST /api/subscriptions/active` and then invalidates the React\nQuery cache, so a switch re-fetches everything rather than mixing two workspaces' data.\n\n== Component organisation\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Directory | Contents\n\n| `components/ui/` | shadcn/ui primitives — *generated, never edited directly*\n| `components/activity/` | Activity feed\n| `components/ai-agents/` | Agent editor, version history, analysis views\n| `components/auth/` | Login and signup forms\n| `components/calls/` | Call tables and filters\n| `components/campaign/` | Campaign detail tabs, targets, calls, the batch re-analyse dialog\n| `components/contacts/` | Contact list and detail\n| `components/email-campaign/` | Email campaign detail, recipients, variations\n| `components/email-domains/` | Domains and sending accounts\n| `components/emails/` | Inbox\n| `components/follow-up-tasks/` | Task board\n| `components/reports/` | Charts\n| `components/sales-reps/` | Rep management\n| `components/sellers/` | Seller list\n| `components/suppliers/` | Payability supplier list and status badges\n| `components/sequences/` | Sequence builder and enrollment views\n| `components/targets/` | Target registries\n| `components/test-cases/` | Test case management\n| `components/mdx/` | MDX renderers for the in-app docs site\n|===\n\n`components/ui/` is off-limits because it is regenerated by the shadcn CLI. Extend by composition — wrap\nthe primitive in a feature component rather than editing it.\n\n== Standard client component\n\n[source,typescript]\n----\n\"use client\"\n\ninterface Props { campaignId: string }\n\nexport function CampaignTargetsTable({ campaignId }: Props) {\n  // 1. Local state\n  const [page, setPage] = useState(1)\n  const [pageSize, setPageSize] = useState(25)\n  const [filters, setFilters] = useState<FilterState>({})\n\n  // 2. Server state\n  const { data, isLoading } = useCampaignTargets(campaignId, { page, pageSize, ...filters })\n\n  // 3. Handlers\n  // 4. JSX\n}\n----\n\nOrder is consistent across the codebase: local state, server state, handlers, JSX.\n\n== Naming\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Kind | Convention\n\n| Files | `kebab-case` — `campaign-targets-table.tsx`\n| Components | `PascalCase` — `CampaignTargetsTable`\n| Hooks | `use*` — `useCampaigns`, `useCampaignTargets`\n| Types | `PascalCase`, derived via `z.infer<>`\n|===\n\n== Import order\n\n. React / Next.js\n. Third-party libraries\n. Internal components (`@/components/...`)\n. Internal utilities (`@/lib/...`)\n. Types\n. Relative imports\n\n== Forms\n\nReact Hook Form with `zodResolver`, always. A dialog form resets and closes on mutation success; errors\nsurface through form state or a `sonner` toast.\n\nThe Zod schema that validates the form is the same one that types the API payload, so a client-side\nvalidation rule cannot drift from the server's.\n\n== Key libraries\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Library | Use\n\n| `@tanstack/react-query` | All server state\n| `react-hook-form` + `@hookform/resolvers` | Forms\n| `recharts` | Report charts\n| `lexical` + `@lexical/*` | Rich-text email editor\n| `mermaid` | In-app diagrams\n| `xlsx` | Spreadsheet import and export\n| `sonner` | Toasts\n| `date-fns` | Date formatting\n| `lucide-react` | Icons\n| `next-themes` | Light/dark switching\n| `react-diff-viewer-continued` | Prompt version diffs\n| `json-edit-react` | JSON payload editing\n| `html-to-image` | Chart export\n| `react-resizable-panels`, `vaul`, `cmdk`, `embla-carousel-react` | Layout and interaction primitives\n|===\n"},{"component":"dialer-v2","module":"frontend","path":"frontend:routes.adoc","title":"Routes","description":"Every dashboard route and what it shows.","url":"https://docx.payability.com/dialer-v2/frontend/routes.html","navOrder":73,"source":"= Routes\n:description: Every dashboard route and what it shows.\n\n== Route groups\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Group | Purpose\n\n| `app/(auth)/` | Unauthenticated shell — `/login`, `/signup`\n| `app/(dashboard)/` | Authenticated shell with sidebar, header and WorkspaceGate\n| `app/docs/` | Fumadocs renderer for the in-app MDX documentation\n| `app/auth/` | OAuth callback handling\n| `app/sentry-example-page/` | Sentry verification page\n|===\n\n== Dashboard routes\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Route | Contents\n\n| `/` | Overview — recent calls and daily statistics\n| `/activity` | Unified activity feed\n| `/agents` | Redirects to `/agents/list`\n| `/agents/list` | AI agent management — create, edit, version, duplicate\n| `/agents/analysis` | Every `CallAiAgentAnalysisResult`, with filters and CSV export\n| `/agents/issues` | Flattened analysis issues, with prompt-suggestion actions\n| `/calls` | All calls, with the full filter set and the batch re-analyse dialog\n| `+/calls/{id}+` | Call detail — transcript, recording, analysis, and history for the same number\n| `/campaigns` | Call campaigns only\n| `+/campaigns/{id}+` | Campaign detail — targets, calls, coverage. Redirects to `+/email-campaigns/{id}+` when `type` is `email`\n| `/contacts` | Contact list\n| `+/contacts/{id}+` | Contact detail — calls, emails, activities, phones, stats\n| `/email-campaigns` | Email campaigns with engagement statistics\n| `+/email-campaigns/{id}+` | Email campaign detail — recipients, variations, statistics\n| `/email-campaigns/templates` | Reusable campaign-less templates\n| `/email-campaigns/variations` | Cross-campaign variation library\n| `/email-campaigns/sending` | Domains and sending accounts\n| `/emails` | Email inbox and threads\n| `/phone-numbers` | Outbound number pool, purchase and the SMS toggle\n| `/profile` | Current user's profile\n| `/reengagement` | Daily re-engagement list (Call summary and Results link to reports)\n| `/reports` | Report index\n| `/reports/daily-stats` | Daily call statistics with totals\n| `/reports/funnel` | Funnel chart\n| `/reports/sankey-chart` | Disposition flow\n| `/reports/daily-agent-performance` | Per-agent performance\n| `/reports/phone-number-pickup-rate` | Pickup rate per number\n| `/reports/email-campaign-daily` | Email engagement per day\n| `/reports/reengagement-daily` | Re-engagement call summary (daily, segment, funnel)\n| `/reports/reengagement-contacts` | Re-engagement contact list with dialled filter and list history\n| `/reports/reengagement-results` | Re-engagement supplier status counts (API, access, Payability)\n| `/sales-reps` | Rep management\n| `+/sales-reps/{id}+` | Rep detail with their calls\n| `+/sales-reps/{id}/tasks+` | That rep's follow-up tasks\n| `/sellers` | Seller list (sidebar hidden)\n| `+/sellers/{id}+` | Seller detail with its contacts\n| `/suppliers` | Payability supplier list — search plus a Filters side pane for API, access, cancelation, status, and payability status; columns include stage, legal signed, and contract signed\n| `+/suppliers/{id}+` | Supplier detail with status, contact, and sync fields\n| `/sequences` | Contact sequence enrollments\n| `+/sequences/{id}+` | Enrollment detail — step timeline with response payloads\n| `/sequences/templates` | Sequence templates\n| `/sequences/templates/new` | Template builder\n| `+/sequences/templates/{id}+` | Template editor\n| `/settings` | Workspace settings — General preferences and a Config tab for `configs` key/value rows\n| `/targets` | Redirects to `/targets/phone-numbers`\n| `/targets/phone-numbers` | Phone registry (`TargetPhoneNumbers`)\n| `/targets/emails` | Placeholder for a future email-targets view\n| `/tasks` | Follow-up task board\n| `/test-cases` | Agent regression test cases\n| `+/test-cases/{id}+` | Test case detail and run results\n| `/workspace` | Member management — owners and admins only\n|===\n\n== Redirect behaviour\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| From | To, and why\n\n| `/agents` | `/agents/list` — the index has no content of its own\n| `/targets` | `/targets/phone-numbers` — the phone registry is the only implemented view\n| `+/campaigns/{id}+` where `type = email` | `+/email-campaigns/{id}+` — the voice dashboard cannot render an email campaign, and a shared campaign id makes the wrong URL easy to reach\n| `/login`, `/signup` with a session | `/` — handled in middleware\n| Any protected route without a session | `/login?redirectedFrom=<path>` — handled in middleware\n|===\n\n`/targets/emails` is deliberately a placeholder: email recipients are campaign-scoped and live under\n*Email campaigns*, so a cross-campaign email-target view has no obvious semantics yet.\n\n== Public routes\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Route | Note\n\n| `/login`, `/signup` | Unauthenticated only\n| `/auth/callback` | OAuth redirect target\n| `/docs/**` | The in-app documentation site, readable without a session\n| `/api/webhooks/**` | Provider callbacks, excluded from middleware entirely\n| `/api/unsubscribe` | Recipients are not users, so this must be public\n| `GET /api/mcp` | Capability discovery, unauthenticated by design\n|===\n"},{"component":"dialer-v2","module":"frontend","path":"frontend:data-layer.adoc","title":"Data layer","description":"React Query hooks, query keys, cache invalidation and schema validation.","url":"https://docx.payability.com/dialer-v2/frontend/data-layer.html","navOrder":74,"source":"= Data layer\n:description: React Query hooks, query keys, cache invalidation and schema validation.\n\nAll server state flows through React Query hooks in `lib/hooks/`, one file per resource. There is no other\nclient-side state store.\n\n== Query hook\n\n[source,typescript]\n----\n\"use client\"\n\nimport { useQuery } from \"@tanstack/react-query\"\nimport { CampaignSchema } from \"@/lib/schema/campaigns\"\n\nexport function useCampaigns() {\n  return useQuery({\n    queryKey: [\"campaigns\"],\n    queryFn: async () => {\n      const response = await fetch(\"/api/campaigns\", { cache: \"no-store\" })\n      if (!response.ok) {\n        const errorBody = await response.json().catch(() => ({}))\n        throw new Error(errorBody.error ?? \"Failed to load campaigns\")\n      }\n      return CampaignSchema.array().parse(await response.json())\n    },\n  })\n}\n----\n\nThree details matter:\n\n`cache: \"no-store\"`::\nStops Next.js adding a second, invisible cache in front of React Query. Two caches with different\ninvalidation rules is the hard kind of bug.\n\nError body extraction::\n`.catch(() => ({}))` guards against a non-JSON error response, and the thrown `Error` carries the API's\nown message so the UI can show something meaningful.\n\n`Schema.parse`::\nValidates at the boundary. A backend change that renames or drops a field fails loudly here instead of\nrendering `undefined` three components deep.\n\n== Mutation hook\n\n[source,typescript]\n----\nexport function useCreateCampaign() {\n  const queryClient = useQueryClient()\n  return useMutation({\n    mutationFn: async (input: CreateCampaignInput) => {\n      const response = await fetch(\"/api/campaigns\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(input),\n      })\n      if (!response.ok) {\n        const errorBody = await response.json().catch(() => ({}))\n        throw new Error(errorBody.error ?? \"Failed to create campaign\")\n      }\n      return CampaignSchema.parse(await response.json())\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"campaigns\"] })\n    },\n  })\n}\n----\n\n== Query keys\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Key | Scope\n\n| `[\"campaigns\"]` | All campaigns\n| `[\"campaign\", id]` | One campaign\n| `[\"campaignTargets\", campaignId, params]` | Paginated targets for a campaign\n| `[\"calls\", campaignId, params]` | Paginated calls\n| `[\"me\"]` | Current user and workspace state\n| `[\"configs\"]` | Workspace `configs` key/value rows\n|===\n\nRules:\n\n* Keep keys stable — a changing key breaks caching silently, because React Query simply treats it as a new\n  query and refetches forever.\n* Include *all* parameters that change the result, so two filter states cannot share a cache entry.\n* Invalidate every related key after a mutation. Creating a target should invalidate both\n  `[\"campaignTargets\", campaignId]` and `[\"campaign\", campaignId]`, because the campaign's aggregate\n  counts changed too.\n\n== Hook inventory\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Area | Hooks\n\n| Campaigns and targets | `use-campaigns`, `use-targets`, `use-campaign-coverage`, `use-target-phone-numbers`, `use-target-emails`\n| Calls | `use-calls`, `use-all-calls`, `use-recent-calls`, `use-daily-calls`, `use-batch-call-analyze`\n| AI agents | `use-ai-agents`, `use-ai-agent-analysis-results`, `use-ai-agent-issues`, `use-prompt-suggestion`, `use-retell-voices`, `use-test-cases`\n| Email | `use-email-campaigns`, `use-email-campaign-targets`, `use-email-campaign-target-summary`, `use-email-variations`, `use-email-domains`, `use-emails`\n| Contacts and people | `use-contacts`, `use-sellers`, `use-sales-reps`, `use-follow-up-tasks`\n| Sequences | `use-sequence-templates`, `use-contact-sequences`\n| Workspace | `use-me`, `use-subscriptions`, `use-workspace-members`, `use-agents`, `use-configs`\n| Reports | `use-daily-stats`, `use-yearly-stats`, `use-daily-agent-performance-chart`\n| Infrastructure | `use-phone-numbers`, `use-activity`, `use-realtime`, `use-mobile`\n|===\n\n== Schema-first types\n\n[source,typescript]\n----\n// lib/schema/reports.ts — the schema is the source of truth\nexport const DailyStatSchema = z.object({ /* … */ })\n\n// lib/types.ts — the type is derived, never hand-written\nexport type DailyStat = z.infer<typeof DailyStatSchema>\n----\n\nNever hand-write a type that a schema already describes. When a Prisma field changes, the schema is the one\nplace to update, and both the API serialiser and the client type follow.\n\n== Pagination and filters\n\n[source,typescript]\n----\nconst [page, setPage] = useState(1)\nconst [pageSize, setPageSize] = useState(25)\nconst [filters, setFilters] = useState<FilterState>({})\n\nconst { data, isLoading } = useCampaignTargets(campaignId, { page, pageSize, ...filters })\n----\n\nFiltering is server-side throughout — list endpoints return `{ data, total, page, pageSize }` and the table\nrenders exactly what it was given. Client-side filtering of a paginated result would filter only the current\npage, which is worse than not filtering at all.\n\nSome newer endpoints use `page_size` rather than `pageSize` (sequences, contact sequences, emails). The hook\ntranslates, so components see one convention.\n\n== Realtime\n\n`lib/hooks/use-realtime.ts` subscribes to Supabase Realtime on the topic `campaign-<campaignId>`. Server-side,\n`RetellCallEventHandler.broadcastEvent` publishes to it during a call's lifecycle. Realtime is used to\n*prompt* a refetch, not as a data source — the invalidated React Query key remains the single path to data.\n"},{"component":"dialer-v2","module":"frontend","path":"frontend:ui-and-theming.adoc","title":"UI conventions and theming","description":"shadcn/ui composition, styling rules and the light/dark token system.","url":"https://docx.payability.com/dialer-v2/frontend/ui-and-theming.html","navOrder":75,"source":"= UI conventions and theming\n:description: shadcn/ui composition, styling rules and the light/dark token system.\n\n== shadcn/ui\n\n`components/ui/` holds generated primitives built on Radix. *Never edit them directly* — they are\nregenerated by the shadcn CLI and any local change is lost. Extend by composition: wrap the primitive in a\nfeature component.\n\n[source,typescript]\n----\n// components/campaign/campaign-status-badge.tsx\nimport { Badge } from \"@/components/ui/badge\"\nimport { cn } from \"@/lib/utils\"\n\nexport function CampaignStatusBadge({ status }: { status: CampaignStatus }) {\n  return (\n    <Badge className={cn(status === \"running\" && \"bg-emerald-600\")}>\n      {status}\n    </Badge>\n  )\n}\n----\n\n== Styling\n\n* Tailwind utilities over custom CSS.\n* `cn()` from `@/lib/utils` for conditional classes — it merges Tailwind classes correctly via\n  `tailwind-merge`, so a later class wins instead of both being emitted.\n* Semantic tokens over palette values: `bg-background`, `text-foreground`, `border-border`, `bg-card`,\n  `text-muted-foreground`. A component using `bg-background` follows the theme automatically; one using\n  `bg-white` breaks in dark mode.\n* `class-variance-authority` for components with variants.\n* `tailwindcss-animate` and `tw-animate-css` for transitions.\n\n== Theme tokens\n\nDefined in `app/globals.css` as CSS custom properties in `oklch`, exposed to Tailwind as semantic colour\nnames.\n\n=== Light\n\n`:root` defines `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`,\n`--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring`, `--chart-1` … `--chart-5`,\n`--radius` (`0.625rem`), and the `--sidebar-*` family.\n\n=== Dark\n\n`.dark` introduces the canonical dark canvas:\n\n[source,css]\n----\n.dark {\n  --payability-dark-canvas: oklch(0.16 0.02 250);\n  --background: var(--payability-dark-canvas);\n  --sidebar:    var(--payability-dark-canvas);\n  --card:       oklch(0.185 0.022 250);\n  --popover:    oklch(0.195 0.022 250);\n  /* … */\n}\n----\n\n`--background` and `--sidebar` both reference `--payability-dark-canvas`, so the page body and the sidebar\nare *one continuous surface* with no seam between them. `--card` and `--popover` are deliberately a step\nlighter, so panels and dialogs lift off that canvas.\n\nChange the canvas in one place and both surfaces follow. Hardcoding either colour breaks that guarantee.\n\n=== The dashboard shell\n\n[source,css]\n----\nhtml:not(.dark) .dashboard-shell { /* navy Payability sidebar — light mode only */ }\nhtml.dark      .dashboard-shell { --sidebar: var(--payability-dark-canvas); /* + Payability green accents */ }\n----\n\nThe navy sidebar is scoped to light mode because in dark mode the sidebar merges into the canvas instead.\nPayability green and its contrast tokens are applied to navigation in both.\n\n=== Theme switching\n\n`next-themes` toggles the `.dark` class on `<html>`. Because every colour is a token, no component needs to\nknow which theme is active.\n\n== Tables\n\nThe standard pattern is server-side pagination and filtering:\n\n[source,typescript]\n----\nconst [page, setPage] = useState(1)\nconst [pageSize, setPageSize] = useState(25)\nconst [filters, setFilters] = useState<FilterState>({})\nconst { data, isLoading } = useCampaignTargets(campaignId, { page, pageSize, ...filters })\n----\n\nShared filter components — `CallsFilter` for example — are reused across pages so the main Calls page and\nthe campaign Calls tab offer an identical filter set.\n\nTimestamps in call views render in Eastern Time with the zone abbreviation (`2026-01-15 07:30:45 EST`),\nmatching the timezone the date filters use. A UTC display next to an Eastern filter is a reliable source of\n\"the filter is broken\" reports.\n\nPhone numbers are masked for display through `lib/utils/mask-phone.ts`.\n\n== Dialogs\n\n* shadcn `Dialog` primitive.\n* React Hook Form with `zodResolver` inside.\n* Reset the form and close on mutation success.\n* Errors through form state, or a `sonner` toast for anything not tied to a field.\n\n== Charts\n\n`components/reports/` wraps Recharts. Colours for disposition nodes come from `SANKEY_NODE_COLOR_MAP` in\n`lib/constants.ts` rather than being chosen per chart, so a disposition is the same colour everywhere.\n`html-to-image` provides export.\n\n== Editors\n\n* `lexical` powers the rich-text email editor for variations and templates.\n* `json-edit-react` edits raw JSON payloads (`payloadJson`, `configJson`, `extraConfigs`).\n* `react-diff-viewer-continued` shows diffs between agent prompt versions.\n\n== Accessibility\n\nRadix primitives supply focus management, keyboard navigation and ARIA attributes. Keep them: replacing a\nRadix component with a bare `div` loses all of it silently.\n"},{"component":"dialer-v2","module":"ops","path":"ops:index.adoc","title":"Operations","description":"Running Dialer V2 — environment, local setup, deployment, scheduled work and incident response.","url":"https://docx.payability.com/dialer-v2/ops/index.html","navOrder":76,"source":"= Operations\n:description: Running Dialer V2 — environment, local setup, deployment, scheduled work and incident response.\n\n== Pages\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Page | Use it to…\n\n| xref:environment-variables.adoc[] | configure an environment, or work out what a missing variable breaks\n| xref:local-development.adoc[] | get the application running on a laptop\n| xref:deployment-and-jobs.adoc[] | understand the build, the cron entries and the `pg_cron` schedulers\n| xref:runbooks.adoc[] | perform a routine operational task — start a campaign, warm a domain, rotate a secret\n| xref:troubleshooting.adoc[] | diagnose \"nothing is dialing\", \"no emails are going out\", and similar\n|===\n\n== Commands\n\n[source,bash]\n----\npnpm dev                 # development server\npnpm build               # next build\npnpm build:prod          # prisma generate + next build\npnpm start               # production server\npnpm lint                # eslint\n\npnpm prisma:generate     # regenerate the client\npnpm prisma:format       # format the schema\npnpm prisma:migrate      # format + db push + generate   (schema push — dev only)\npnpm prisma:migrate:dev  # format + migrate dev + generate\npnpm prisma studio       # browse data\n----\n\n`pnpm prisma:migrate` uses `db push`, which changes the database *without* creating a migration file. Use it\nonly for local iteration; anything that reaches an environment needs `prisma:migrate:dev`.\n\nThere is no test runner. Write testable code — pure functions, injected dependencies — but do not add test\ninfrastructure unless it is explicitly requested.\n\n== Operational surfaces\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Surface | Where\n\n| Application logs | Vercel; search by the bracketed prefixes handlers use, e.g. `[broadcast]`, `[email-send]`, `[Nooks]`\n| Errors and traces | Sentry, tagged with `subscriptionId` and the Sentry user\n| Workflow runs | The Inngest dashboard — per-function, per-step, with replay\n| Raw webhook payloads | The `WebhookEvent` table\n| Database | Supabase dashboard, or `pnpm prisma studio`\n| Schedulers | `pg_cron` jobs in Supabase; Inngest cron functions in the Inngest dashboard\n|===\n\n== The three things that stop outbound work\n\nWorth knowing before an incident rather than during one.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Symptom | First place to look\n\n| No calls | Campaign `status`, then the Redis in-flight counter, then the `pg_cron` job and `INTERNAL_API_SECRET`\n| No emails | Active variations and their hourly rate, then active sending accounts and their daily caps\n| No analyses | Whether an analysis agent is configured for the workspace, then `AI_GATEWAY_API_KEY`\n|===\n\nxref:troubleshooting.adoc[] expands each of these.\n"},{"component":"dialer-v2","module":"ops","path":"ops:environment-variables.adoc","title":"Environment variables","description":"Every variable, what reads it, and what breaks when it is missing.","url":"https://docx.payability.com/dialer-v2/ops/environment-variables.html","navOrder":77,"source":"= Environment variables\n:description: Every variable, what reads it, and what breaks when it is missing.\n\nNames below are exactly as the code reads them. Several are prefixed `NEXT_` without being\n`NEXT_PUBLIC_` — those are *server-only* despite the prefix.\n\n== Database\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `DATABASE_URL` | Pooled connection used at runtime | Nothing works\n| `DIRECT_URL` | Direct connection used by migrations | Migrations fail\n|===\n\n== Supabase\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `NEXT_PUBLIC_SUPABASE_URL` | Project URL (client and server) | Middleware allows all requests through; Realtime disabled\n| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Anon key | As above\n| `SUPABASE_SERVICE_ROLE_KEY` | Admin client | Admin operations fail\n| `SUPABASE_GOOGLE_CLIENT_ID` | Google OAuth | Google sign-in unavailable\n| `SUPABASE_GOOGLE_CLIENT_SECRET` | Google OAuth | As above\n| `NEXT_SUPABASE_URL` | Legacy alias referenced in some code paths | Depends on the path\n|===\n\nMiddleware deliberately allows requests through when the anon config is absent — auth cannot be enforced,\nand failing every request would make a misconfigured environment unreachable. Never deploy without it.\n\n== Retell AI\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `NEXT_RETELLAI_API_KEY` | Read into `RETELLAI_API_KEY` | No calls, no SMS, no agent sync\n| `NEXT_RETELLAI_BASE_URL` | API base override | Uses the SDK default\n| `NEXT_RETELL_OUTBOUND_AGENT_ID` | Default outbound agent | Falls back to the agent registry\n| `RETELL_API_KEY` | Referenced in some paths alongside the `NEXT_` variant | Set both to the same value\n|===\n\n`RETELL_SMS_AGENT_ID` is hardcoded in `lib/constants.ts`, not an environment variable.\n\n== Telnyx\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `TELNYX_PUBLIC_KEY` | Ed25519 verification of `POST /api/webhooks/telnyx`. Base64 raw public key from the Telnyx Mission Control portal\n| Inbound webhooks are *rejected* (`503`)\n|===\n\n== ElevenLabs\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `ELEVENLABS_WEBHOOK_SECRET` | HMAC verification of `POST /api/webhooks/elevenlabs`. Secret shown when the ElevenLabs workspace webhook is created\n| Inbound webhooks are *rejected* (`503`)\n|===\n\n== Carriers and number purchase\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `NEXT_MESSAGING_APP_API_URL` | MessagingApp base URL | Purchase and release fail\n| `NEXT_MESSAGING_APP_API_KEY` | MessagingApp key | As above\n| `PHONE_NUMBER_DEFAULT_COUNTRY` | Defaults to `US` | Defaults applied\n| `NEXT_TWILIO_TRUNK_SID` | Twilio SIP trunk | Twilio import fails\n| `NEXT_TWILIO_TERMINATION_URL` | Twilio termination URL | As above\n| `NEXT_PLIVO_TRUNK_SID` | Plivo SIP trunk | Plivo import fails\n| `NEXT_PLIVO_TERMINATION_URL` | Plivo termination URL | Falls back to a hardcoded default\n|===\n\n== Email\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `RESEND_API_KEY` | Sending | No email is sent\n| `RESEND_WEBHOOK_SECRET` | Svix verification | *Verification is skipped* — never omit in a deployed environment\n| `MAILTRAP_API_KEY` | Present in the environment file; not used by the current send path | —\n|===\n\n== HubSpot\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `HUBSPOT_PRIVATE_APP_TOKEN` | Direct API calls | Engagement logging fails (best-effort, so nothing else breaks)\n| `HUBSPOT_WEBHOOK_CLIENT_SECRET` | Inbound signature validation | Inbound webhooks are *rejected*\n| `HUBSPOT_CLIENT_ID` | OAuth app | OAuth flows unavailable\n| `HUBSPOT_CLIENT_SECRET` | OAuth app | As above\n| `HUBSPOT_REFRESH_TOKEN` | OAuth refresh | As above\n| `HUBSPOT_OWNER_ID` | Read into `HUBSPOT_AI_OWNER_ID`; defaults to `86817215` | Default applied\n| `HUBSPOT_CONTACT_SYNC_SUBSCRIPTION_ID` | Workspace that receives inbound HubSpot contacts, calls, and Payability suppliers from BigQuery | Contact, call, and supplier import crons return `missing_subscription` and do not query BQ\n|===\n\n== AWS SQS\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `AWS_REGION` | SQS region | No CRM or categorisation messages\n| `AWS_ACCESS_KEY_ID` | Credentials | As above\n| `AWS_SECRET_ACCESS_KEY` | Credentials | As above\n| `CRM_QUEUE_URL` | CRM event queue | CRM hand-off silently stops\n| `INCOMING_EMAIL_SQS_QUEUE_URL` | Reply categorisation; defaults to the `morpheus-sam-email-agent` queue | Default applied\n| `EMAIL_VARIATION_QUEUE_URL` | Variation generation requests | That path is unavailable\n|===\n\nPublishers return `false` rather than throwing, so a missing queue URL degrades to \"not published\". Watch the\nlogs, because nothing else will complain.\n\n== AI\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `AI_GATEWAY_API_KEY` | Vercel AI Gateway | No call analysis, variation generation, prompt suggestions or test runs\n| `AI_GATEWAY_BASE_URL` | Defaults to `https://ai-gateway.vercel.sh/v1` | Default applied\n| `OPENAI_API_KEY` | Direct OpenAI access in some helpers | Depends on the path\n| `ANTHROPIC_API_KEY` | Direct Anthropic access in some helpers | Depends on the path\n|===\n\n== BigQuery\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `BIGQUERY_PROJECT_ID` | Defaults to `bigqueryexport-183608` | Default applied\n| `BIGQUERY_PRIVATE_KEY` | Service-account PEM, with `\\n` escaped | Sequence data gates fail\n| `BIGQUERY_PRIVATE_KEY_ID` | Key id | As above\n| `BIGQUERY_CLIENT_ID` | Client id | As above\n|===\n\nThe PEM is stored with escaped `\\n` and restored to real newlines at runtime. Paste it escaped.\n\n== Redis\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `REDIS_URL` | In-flight call counter | Broadcast throttling misbehaves — investigate before running campaigns\n|===\n\n== Machine secrets\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `INTERNAL_API_SECRET` | Broadcast and internal endpoints | *Every* broadcast returns `401` — all campaigns stop\n| `MCP_API_SECRET` | MCP tool server | MCP returns `-32603` (HTTP 500)\n| `INNGEST_EVENT_KEY` | Sending Inngest events | Workflows are never triggered\n| `INNGEST_SIGNING_KEY` | Serving Inngest functions | Inngest cannot invoke functions\n| `NOOKS_WEBHOOK_SECRET` | Nooks verification | Verification skipped\n| `NOOKS_API_KEY` | Present; not used by the webhook path | —\n|===\n\n`INTERNAL_API_SECRET` fails closed. An unset value stops all outbound work, which is by design — the\nalternative is an open broadcast endpoint.\n\n== Monitoring\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `NEXT_PUBLIC_SENTRY_DSN` | Sentry DSN | No error reporting\n| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | Environment tag | Events are untagged\n|===\n\n`.env.sentry-build-plugin` holds the build-time source-map upload token.\n\n== Application\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Variable | Purpose | Missing\n\n| `NEXT_PUBLIC_APP_URL` | Base URL for unsubscribe links | Falls back to `https://$VERCEL_URL`, then `http://localhost:3000`\n| `VERCEL_URL` | Provided by Vercel | Used as the fallback base URL\n| `NODE_ENV` | Standard | —\n| `NEXT_RUNTIME` | Set by Next.js; used to branch Sentry init | —\n| `DEBUG` | Verbose logging | —\n|===\n\nAn incorrect `NEXT_PUBLIC_APP_URL` produces unsubscribe links pointing at the wrong host — a compliance\nproblem, not just a broken link. Set it explicitly in production.\n\n== Client-exposed variables\n\nOnly these reach the browser: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`,\n`NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `NEXT_PUBLIC_APP_URL`.\n\nEverything else is server-only, including the `NEXT_`-prefixed Retell and MessagingApp variables.\n\n== Where they are read\n\nMost are centralised in `lib/constants.ts` and `lib/ai/config.ts`, which read `process.env` at *module\nload*. A running instance therefore keeps the value it started with — changing a variable requires a\nredeploy, not just a save.\n"},{"component":"dialer-v2","module":"ops","path":"ops:local-development.adoc","title":"Local development","description":"Getting Dialer V2 running on a laptop, and what works without every integration.","url":"https://docx.payability.com/dialer-v2/ops/local-development.html","navOrder":78,"source":"= Local development\n:description: Getting Dialer V2 running on a laptop, and what works without every integration.\n\n== Prerequisites\n\n* Node 20 (`.nvmrc`)\n* pnpm\n* A Supabase project — PostgreSQL and Auth\n* Redis, for the in-flight call counter\n\n== Setup\n\n[source,bash]\n----\ngit clone <repository-url>\ncd dialer-v2\nnvm use\npnpm install\n----\n\nCreate `.env` (git-ignored) with at minimum:\n\n[source,bash]\n----\nDATABASE_URL=\nDIRECT_URL=\nNEXT_PUBLIC_SUPABASE_URL=\nNEXT_PUBLIC_SUPABASE_ANON_KEY=\nSUPABASE_SERVICE_ROLE_KEY=\nREDIS_URL=redis://localhost:6379\n----\n\nThen add integrations for whatever you are working on — see xref:environment-variables.adoc[].\n\n[source,bash]\n----\npnpm prisma generate\npnpm prisma migrate deploy   # or `pnpm prisma migrate dev` against a scratch database\npnpm dev\n----\n\nVisit `http://localhost:3000`.\n\n== Signing in\n\nSignup requires a `@payability.com` address (or a subdomain) — `isAllowedPayabilityEmail()` in middleware\nand a Supabase auth hook both enforce it. A different domain is signed out immediately with\n`?error=forbidden_domain`.\n\n`POST /api/auth/register` creates the `Profile`, `Subscription` and owner `SubscriptionUser` in one step, so a\nfresh signup lands in a working workspace.\n\n== What works without every integration\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Missing | Consequence\n\n| `NEXT_RETELLAI_API_KEY` | Browsing works; dialing fails at the Retell step and the target is marked `failed` with `retell_error`\n| `RESEND_API_KEY` | Email campaigns can be configured but not sent\n| `AI_GATEWAY_API_KEY` | Call analysis, variation generation, prompt suggestions and test runs all fail\n| `CRM_QUEUE_URL` / AWS credentials | CRM publishes silently no-op; watch the logs\n| BigQuery credentials | Sequence data gates fail; other sequence channels still work\n| `REDIS_URL` | Broadcast throttling misbehaves — fix this before running a campaign locally\n| `INTERNAL_API_SECRET` | Broadcasts return `401`; call them manually with the header instead\n|===\n\nThe dashboard, contacts, registries, campaign and sequence configuration, and reporting (against whatever\ndata exists) all work with just the database and Supabase.\n\n== Triggering work manually\n\nNothing schedules itself locally — there is no `pg_cron`. Invoke the loops by hand:\n\n[source,bash]\n----\n# one dial tick\ncurl -X POST http://localhost:3000/api/campaigns/<campaignId>/broadcast \\\n  -H \"Authorization: Bearer $INTERNAL_API_SECRET\" \\\n  -H \"Content-Type: application/json\" -d '{}'\n\n# one email tick\ncurl -X POST http://localhost:3000/api/email-campaigns/<campaignId>/broadcast \\\n  -H \"Authorization: Bearer $INTERNAL_API_SECRET\" \\\n  -H \"Content-Type: application/json\" -d '{}'\n----\n\nThe daily resets and the phone-number optimizer are Inngest cron functions\n(`phoneNumberDailyResetCron`, `sendingDomainDailyResetCron`, `phoneNumberOptimizerCron`). Run them\nwith *Invoke* in the Inngest dev server UI (see below).\n\n== Inngest locally\n\nWorkflows need the Inngest dev server:\n\n[source,bash]\n----\nnpx inngest-cli@latest dev -u http://localhost:3000/api/inngest\n----\n\nIts UI (usually `http://localhost:8288`) shows runs, step results and lets you replay. Without it, events are\nsent and never consumed — which looks exactly like a broken workflow, so check the dev server first.\n\n== Replaying a webhook\n\nReal payloads are already in the database:\n\n[source,sql]\n----\nselect \"eventType\", \"rawPayload\"\nfrom \"WebhookEvent\"\nwhere source = 'Retell' and \"eventType\" = 'call_analyzed'\norder by \"receivedAt\" desc\nlimit 1;\n----\n\n`curl` it back at the endpoint. The `WebhookEvent` upsert is idempotent on `(source, eventId)`, so the row is\nnot duplicated, but the *handler* runs again — which is the point.\n\n== Data safety\n\n`.env` typically points at a shared Supabase project. Assume writes are real:\n\n* Set `CampaignTarget.isTestTarget = true` on anything you plan to dial, which excludes it from CRM\n  hand-off.\n* Prefer `draft` campaigns; only `running` campaigns broadcast.\n* `pnpm prisma migrate reset` destroys data — never run it against a shared database.\n* Test email sends go to real inboxes and count against real daily caps.\n\n== The in-app docs site\n\nThe Fumadocs site at `/docs` is built from the `.mdx` files directly under `docs/`\n(`source.config.ts`, `lib/source.ts`, `app/docs/`). It is public — no session required — and hot-reloads\nin development.\n\nThis Antora component lives under `docs/modules/` and is not part of that site. To preview it, point an\nAntora playbook at the repository with `start_path: docs`; see\nxref:ROOT:doc-conventions.adoc[].\n\n== Common setup problems\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Symptom | Fix\n\n| `@prisma/client did not initialize` | `pnpm prisma generate`\n| Type errors on Prisma models after a schema edit | `pnpm prisma generate` — the client is generated into `prisma/generated`\n| Stale build after dependency changes | `rm -rf .next && pnpm install && pnpm build`\n| Redirected to `/login` in a loop | Missing or wrong Supabase anon config, or a non-Payability email\n| Events sent but no workflow runs | The Inngest dev server is not running\n|===\n"},{"component":"dialer-v2","module":"ops","path":"ops:deployment-and-jobs.adoc","title":"Deployment and scheduled jobs","description":"Build pipeline, Vercel configuration, scheduled Inngest jobs and the pg_cron schedulers.","url":"https://docx.payability.com/dialer-v2/ops/deployment-and-jobs.html","navOrder":79,"source":"= Deployment and scheduled jobs\n:description: Build pipeline, Vercel configuration, scheduled Inngest jobs and the pg_cron schedulers.\n\n== Build\n\n[source,bash]\n----\npnpm build:prod   # prisma generate && next build\n----\n\n`prisma generate` must run before `next build`, because the client is generated into `prisma/generated`\nand imported as `@/prisma/generated/client`. A build without it fails on missing types.\n\nRelated configuration:\n\n* `next.config.mjs` — MDX support, the Sentry plugin, image and build settings\n* `source.config.ts` — Fumadocs MDX pipeline for the in-app docs\n* `postcss.config.mjs` — Tailwind 4 via `@tailwindcss/postcss`\n* `tsconfig.json` — path aliases (`@/*`) and strict mode\n* `instrumentation.ts`, `instrumentation-client.ts` — Sentry instrumentation hooks\n\n== Vercel\n\nThe application deploys to Vercel. Environment variables are configured per environment there; nothing\nsensitive is committed.\n\n`lib/constants.ts` and `lib/ai/config.ts` read `process.env` at *module load*, so changing a variable\nrequires a redeploy — a running instance keeps its original value.\n\n=== Scheduled jobs\n\nThe app has no Vercel crons and no `vercel.json`. Global scheduled jobs are Inngest cron functions,\nserved through `/api/inngest` like every other function (see <<_inngest>>):\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Function | Schedule | Effect\n\n| `phoneNumberDailyResetCron` | Daily 00:00 UTC | `resetDailyCounts()` — zeroes `PhoneNumbers.dailyCallCount`\n| `sendingDomainDailyResetCron` | Daily 00:00 UTC | Zeroes `EmailSendingDomain.dailySentCount` and clears `dailySentDate`. Lifetime `totalSentCount` is untouched\n| `phoneNumberOptimizerCron` | Monday 11:15 UTC | `findReleasableNumbers(3, 10)`, then one `releasePhoneNumberComplete` step per number unused for 3+ days with fewer than 10 lifetime calls\n|===\n\nBoth resets run at UTC midnight, which is *not* midnight Eastern. Daily caps therefore reset mid-evening\nlocal time. This is intentional for consistency across regions, but it means a cap can be consumed twice\nwithin one Eastern-time day.\n\nThe email daily cap does not depend on the reset running: each account's counter is stamped with its\nUTC day (`dailySentDate`), and a counter from an earlier day counts as zero. The reset is a backstop.\nTo run any of these jobs now, invoke the function from the Inngest dashboard.\n\n=== Firewall\n\nThe Vercel WAF custom rules are defined in `vercel.firewall.json` and applied from a developer machine\nwith `pnpm firewall:apply`, not edited in the dashboard. There is no CI job: merging a change to the file\ndoes not apply it. The app is internal-only, so everything except machine-called routes must come from the\ncompany VPN.\n\nRules are evaluated in order; a `bypass` skips the remaining custom rules.\n\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Rule | Matches | Action\n\n| Allow Inngest\n| Path starts with `/api/inngest` *and* the client IP is one of Inngest's published egress IPs\n| `bypass`\n\n| Allow machine routes\n| `/api/webhooks/`, `/.well-known/workflow/` (Vercel Workflow callbacks),\n  `/api/unsubscribe`, `/api/mcp`, and `+/api/{campaigns,email-campaigns}/{id}/broadcast+`\n| `bypass`\n\n| VPN only\n| Client IP is *not* a VPN exit IP: `52.70.159.172` (the OpenVPN networking-application connector, confirmed from Vercel traffic) and the AWS NAT gateways `52.70.240.125`, `3.228.233.93`\n| `log` during rollout, then `deny`\n|===\n\nThe machine routes rely on their own auth (`INTERNAL_API_SECRET`, `MCP_API_SECRET`,\nwebhook signatures, the Inngest signing key). A request to `/api/inngest` from any other IP falls\nthrough to *VPN only*.\n\nThe VPN only routes the app's domains through the AWS connector (CloudConnexa domain routing), so the\nNAT gateways' public IPs are what Vercel sees. The VPN tunnel addresses (`10.100.60.x`) and the\ndomain-routing overlay addresses (`100.80.0.0/12`) never reach Vercel. The two NAT gateway IPs are also\nin the API Gateway resource policy; change both together. The connector IP must be an Elastic IP: an\nauto-assigned public IP changes when the instance restarts and locks everyone out. Preview URLs (`*.vercel.app`) are covered by the same\nrules and need to be in the VPN's domain routing too.\n\n==== Applying changes\n\n[source,bash]\n----\nexport VERCEL_FIREWALL_TOKEN=...   # Vercel token with access to the project\nexport VERCEL_PROJECT_ID=...\nexport VERCEL_TEAM_ID=...\npnpm firewall:plan    # validate vercel.firewall.json and diff it against the live config\npnpm firewall:apply   # same, then PUT the config and verify Vercel published it\n----\n\nReview the change in a pull request first, then run `firewall:plan` and `firewall:apply` from the\nmerged `main`, so the live config always matches the committed file. Without a token, `firewall:plan`\nonly validates the file. `pnpm test` covers the validation and sync logic.\n\nThe script refuses to apply a file that would lock everyone out: private, CGNAT and documentation\naddresses, an empty VPN list, or a blocking rule ahead of a bypass rule. The `PUT` endpoint overwrites\nthe whole config, so the script copies IP blocks, managed rulesets, OWASP rules, BotID and log headers\nfrom the live config. Custom rules added in the dashboard are removed on the next apply; the plan lists\nthem as removals.\n\n==== Break-glass\n\nIf the firewall blocks legitimate traffic, disable the VPN rule from the CLI or restore an earlier\nconfig from the dashboard (*Firewall* > ⋯ > *View Audit Log*):\n\n[source,bash]\n----\nvercel firewall rules disable \"VPN only\" --yes\nvercel firewall publish --yes\n----\n\nThen fix `vercel.firewall.json`: the next `firewall:apply` restores whatever the file says.\n\n== `pg_cron` schedulers\n\nCampaign loops are scheduled in Supabase, not Vercel, because they are per-campaign rather than global.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Job | Calls\n\n| Per call campaign | `+POST /api/campaigns/{id}/broadcast+`\n| Per email campaign | `+POST /api/email-campaigns/{id}/broadcast+`, at `Campaign.schedulerIntervalSeconds` (default 30 s)\n|===\n\nBoth require `Authorization: Bearer <INTERNAL_API_SECRET>`.\n\n`pg_cron` also maintains the daily report rollups. These call SQL functions directly rather than HTTP\nendpoints, so they need no secret:\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Job | Schedule | Calls\n\n| `refresh-call-daily-stats` | every 10 min | `refresh_call_daily_stats(today - 3, today)`\n| `resync-call-daily-stats` | 04:20 daily | `refresh_call_daily_stats(today - 30, today)`\n| `refresh-agent-daily-performance-stats` | every 10 min | `refresh_agent_daily_performance_stats(today - 3, today)`\n| `resync-agent-daily-performance-stats` | 04:35 daily | `refresh_agent_daily_performance_stats(today - 30, today)`\n| `refresh-v_email_campaign_stats` | hourly, at minute 0 | `REFRESH MATERIALIZED VIEW CONCURRENTLY v_email_campaign_stats`\n|===\n\n`refresh-v_email_campaign_stats` re-aggregates every email campaign target. Its cost grows with\nthat table, not with call history. Email campaign list and detail counts can lag until the next\nrun; campaign settings on those APIs still come from the live `Campaign` row. If counts look\nfrozen, check `cron.job_run_details` for a `statement_timeout` on that job.\n\nThe daily rollup jobs recompute a trailing window rather than the whole history, so runtime stays\nflat as call volume grows. See xref:domain:reporting.adoc#rollups[Daily report rollups].\n\n[IMPORTANT]\n====\n`pg_cron` job definitions live in the Supabase project, not in this repository. Rotating\n`INTERNAL_API_SECRET` requires updating both the Vercel environment and every job definition in the same\nwindow — broadcasts return `401` until they match.\n====\n\n[TIP]\n====\nIf a report looks stale, check `cron.job_run_details` before suspecting the route. The predecessors of\nthese jobs (`refresh-main-campaign-report` and `refresh-vm_main_daily_agent_performance_report`) ran a\nfull `REFRESH MATERIALIZED VIEW` whose cost grew with history, and silently failed on the 120 s\n`statement_timeout` for a month.\n====\n\n== Inngest\n\nFunctions are served from `/api/inngest` and registered by being listed in the `serve()` call. Inngest is\nconfigured to point at the deployment URL and authenticates with `INNGEST_SIGNING_KEY`.\n\nAfter adding a function, verify it appears in the Inngest dashboard. A function missing from the\n`functions` array is silently never invoked — its events are accepted and dropped.\n\n== Supabase Edge Functions\n\n`lib/supabase/functions/` holds Deno functions deployed to Supabase separately from the Vercel build:\n`event-logger`, `campaign-scheduler`, `hello-world`. They are not part of `pnpm build`.\n\n== Migrations\n\n[source,bash]\n----\npnpm prisma migrate deploy   # apply pending migrations\n----\n\nRun against `DIRECT_URL`, not the pooled connection. Never edit an applied migration; add a new one.\n\nObjects managed in Supabase rather than by Prisma — and therefore *not* applied by `migrate deploy`:\n\n* the `get_and_lease_due_targets` RPC\n* reporting views (`vm_main_campaign_reports`, `main_required_summaries`, `main_sankey_chart_view`,\n  `vm_main_daily_agent_performance_report`, `phone_number_pickup_rate`,\n  `v_email_campaign_daily_engagement`, `v_email_campaign_stats`)\n* `v_email_campaign_stats` is a materialized view, scripted in\n  `lib/supabase/views/v_email_campaign_stats.sql`, refreshed hourly by\n  `refresh-v_email_campaign_stats`\n* the daily report rollup tables and their refresh functions — `call_daily_stats` and\n  `agent_daily_performance_stats`, scripted in `lib/supabase/sql/call_daily_stats.sql` and\n  `lib/supabase/sql/agent_daily_performance_stats.sql`\n* the `handle_justcall_call_initiated()` trigger function\n* the `auth_hook_before_user_created_payability` auth hook\n* `pg_cron` job definitions\n\nA new environment needs these created by hand. `prisma/sql/` holds the auth hook and the historical\nbackfills.\n\n== Deployment checklist\n\n. Environment variables set for the target environment, including `INTERNAL_API_SECRET`,\n  `MCP_API_SECRET`, `RESEND_WEBHOOK_SECRET`, `HUBSPOT_WEBHOOK_CLIENT_SECRET`,\n  `TELNYX_PUBLIC_KEY`, and `ELEVENLABS_WEBHOOK_SECRET`.\n. Firewall applied (`pnpm firewall:plan` shows no changes) and a dashboard page loads over the VPN.\n. `NEXT_PUBLIC_APP_URL` correct — it appears in unsubscribe links.\n. Migrations applied.\n. Supabase-managed objects present (RPC, views, trigger, auth hook).\n. `pg_cron` jobs pointing at the correct host with the correct secret.\n. Inngest pointing at `<host>/api/inngest`, with every function visible in its dashboard.\n. Provider webhooks pointing at the correct host: Retell → `/api/webhooks/event-logger`,\n  Resend → `/api/webhooks/resend`, Nooks → `/api/webhooks/nooks`, HubSpot → `/api/webhooks/hubspot`,\n  Telnyx → `/api/webhooks/telnyx`.\n. Sentry receiving events — verify with `/api/sentry-example-api`.\n"},{"component":"dialer-v2","module":"ops","path":"ops:runbooks.adoc","title":"Runbooks","description":"Routine operational procedures.","url":"https://docx.payability.com/dialer-v2/ops/runbooks.html","navOrder":80,"source":"= Runbooks\n:description: Routine operational procedures.\n\n== Launch a call campaign\n\n. Create the campaign (`draft`) and set `maxConcurrentCalls`, `maxAttempts`, `waitHoursToRecall`,\n  `windowStart`/`windowEnd`, `allowWeekends` and `preferredAiAgentId`. Set `maxAttempts` explicitly —\n  its fallback differs between code paths.\n. Import targets. Timezones are derived from area codes; a HubSpot contact id may be supplied in the\n  payload as `Record ID`, `record_id`, `RecordId` or `hubspot_id`.\n. Check coverage — `+GET /api/campaigns/{id}/coverage+`. States with targets but no local source number will\n  fall back to a non-local number, which lowers pickup rates.\n. Confirm the workspace has active `caller`-type AI agents; otherwise the static\n  `RETELL_AGENT_LIST` fallback is used.\n. Set `status = running`.\n. Create the `pg_cron` job for `+POST /api/campaigns/{id}/broadcast+` with the internal secret.\n. Watch the first ticks: `callsInitiated`, `callsSkipped` and any throttle message in the broadcast\n  response, plus `callTarget` runs in Inngest.\n\nVerify the Redis counter is falling back to zero between ticks. If it only rises, `callTarget` is not\nreaching its `finish()` helper and the campaign will throttle itself into silence.\n\n== Pause or stop a campaign\n\nSet `status` to `paused` or `stopped`. `processBroadcast` throws `CAMPAIGN_NOT_RUNNING` for anything other\nthan `running`, so the next tick stops enqueueing.\n\nAlready-enqueued Inngest events still run — pausing does not recall in-flight calls. To stop those, disable\nthe `pg_cron` job as well and let the queue drain.\n\n== Launch an email campaign\n\n. Verify the domain in Resend, then create the `Domain` row with its `resendDomainId` from\n  `GET /api/email-domains/resend-domains`.\n. Add at least one active, verified `EmailSendingDomain` under it, with a `dailyCap`.\n. Create the campaign with `schedulerIntervalSeconds`, `emailsPerHour`, `maxEmailsPerDay` and\n  `emailsPerVariationPerHour`.\n. Add at least one *active* variation. A campaign with no active variations halts with\n  `throttled: false` — the condition never clears on its own.\n. Import recipients. Contacts are upserted first, so every target gets a `contactId` and an `emailId`.\n. Send a test through `+POST /api/email-campaigns/{id}/send+` and confirm rendering, the unsubscribe link and\n  the `Reply-To`.\n. Set `status = running` and create the `pg_cron` job.\n. Watch the broadcast response: `emailsEnqueued`, `throttled`, `throttleReason`,\n  `variationGenerationRequested`.\n\n`throttled: true` clears on its own. `throttled: false` with zero enqueued means an operator has to act.\n\n== Warm a sending domain\n\n. Create the `Domain` with `warmupStatus = warming_up` and a low `dailyCap`.\n. Add one account with a low `dailyCap`.\n. Raise both caps gradually over days, watching `bouncedCount` and `unsubscribedCount` per account.\n. Set `warmupStatus = active` once volume is steady. The status is a label — it does not gate sending, so\n  the caps are what actually protect the domain.\n\nA single bad account damages the reputation of its whole parent domain, which is why the parent cap exists\nas well.\n\n== Suppress a recipient\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Goal | Action\n\n| Stop all marketing email to an address | Set `newsletterOptOut` on its `TargetEmails` row\n| Block an address entirely | Set `isBlocked` — treated as suppressed everywhere\n| Stop calling a number | Set `isBlocked` on its `TargetPhoneNumbers` row\n| Stop calling a contact | Set `Contact.stopCalling = true`\n|===\n\nRegistry-level blocks apply across *every* campaign, present and future. Marking a single campaign target\n`canceled` only affects that campaign.\n\nA DNC outcome does the registry block automatically — `callEndedAnalysis` sets\n`TargetPhoneNumbers.isBlocked = true`.\n\n== Publish a new agent prompt\n\n. Duplicate the agent (`+POST /api/ai-agents/{id}/duplicate+`) if you want to experiment without touching\n  production.\n. Review `/agents/issues` for recurring problems, and use\n  `POST /api/ai-agents/issues/suggest-prompt` for a starting revision.\n. Run the regression suite — `POST /api/test-cases/run` against the candidate version. `passed` is true\n  only when every *asserted* field matches.\n. Publish a new version (`+POST /api/ai-agents/{id}/versions+`). Versions are immutable and the change is\n  mirrored to Retell.\n. Watch `/reports/daily-agent-performance` — `avg_score`, `correct_disposition_pct` and\n  `total_critical_issues` are per agent *version*, so the new prompt is directly comparable to the old one.\n\n== Re-analyse calls after a prompt change\n\n. Open the re-analyse dialog on `/calls` or a campaign's Calls tab.\n. Set filters — campaign, status, date range, HubSpot sync status.\n. Check the preview count before starting; it uses the same `where` builder as the run, so it cannot\n  disagree.\n. Start it. Results land as new `CallAiAgentAnalysisResult` rows keyed by\n  `(callId, aiAgentId, aiAgentVersionId)`, so the old verdicts are preserved for comparison.\n\nNooks calls have `campaignId = null` and are excluded from batch runs — re-run those individually.\n\n== Rotate a secret\n\n. Update the value in Vercel for the target environment.\n. Redeploy — module-load reads mean a running instance keeps the old value.\n. For `INTERNAL_API_SECRET`, update every `pg_cron` job definition in Supabase in the same window.\n  Broadcasts return `401` until both sides match.\n. For provider webhook secrets, update the provider's dashboard *first*, then the environment. A brief\n  window of skipped or failed verification is preferable to a window of dropped events.\n\n== Add a carrier\n\n. Add an entry to `EXTERNAL_PROVIDER_CONFIGS` in `lib/constants.ts` with `label`, `source`, `trunkSid` and\n  `terminationUrl`.\n. Add the corresponding environment variables.\n. Deploy. `PhoneNumberCallingService`, `MessagingAppPhoneService`, the purchase route and the Phone Numbers\n  page all read the registry dynamically — no other change is needed.\n\n== Release unused numbers\n\nThe Inngest function `phoneNumberOptimizerCron` runs weekly (Monday 11:15 UTC) and releases numbers\nunused for 3+ days with fewer than 10 lifetime calls. To run it now, open it in the Inngest dashboard\nand choose *Invoke*. Each release is its own step, so the run page shows which numbers were released\nand which failed.\n\nCheck `/reports/phone-number-pickup-rate` first — a number with poor pickup is often better rested than\nreleased, since a release means buying a new one later.\n\n== Replay a webhook\n\n[source,sql]\n----\nselect \"eventType\", \"rawPayload\"\nfrom \"WebhookEvent\"\nwhere source = 'Retell' and \"providerCallId\" = '<call_id>'\norder by \"receivedAt\" desc;\n----\n\n`curl` the payload back at the endpoint. The `WebhookEvent` row is idempotent on `(source, eventId)`, but\nthe handler runs again — which is the point. Downstream, `Call.providerCallId` uniqueness and the\n`emailSend` status guard absorb the duplicate side effects.\n"},{"component":"dialer-v2","module":"ops","path":"ops:troubleshooting.adoc","title":"Troubleshooting","description":"Diagnosing the failures that actually happen.","url":"https://docx.payability.com/dialer-v2/ops/troubleshooting.html","navOrder":81,"source":"= Troubleshooting\n:description: Diagnosing the failures that actually happen.\n\n== No calls are being made\n\nWork down the chain; each step rules out the one above.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| # | Check | If wrong\n\n| 1 | `Campaign.status` is `running` | Anything else and `processBroadcast` throws `CAMPAIGN_NOT_RUNNING`\n| 2 | The `pg_cron` job exists and is enabled in Supabase | No tick, no dials\n| 3 | The broadcast returns `200`, not `401` | `INTERNAL_API_SECRET` mismatch between Vercel and the job definition\n| 4 | The response's `message` field | `\"Throttled: N/M in-flight runs\"` means the Redis counter is at the ceiling\n| 5 | The Redis counter falls back toward zero between ticks | If it only rises, `callTarget` is not reaching `finish()` and capacity is leaking\n| 6 | Targets are due — `state = pending` (or `in_progress`) with `nextEarliestAttemptAt` in the past and `leaseExpiresAt` expired | Everything may be inside its 24 h cooling-off window\n| 7 | `callTarget` runs appear in Inngest | If not, `INNGEST_EVENT_KEY` / `INNGEST_SIGNING_KEY`, or the function is missing from the `serve()` array\n| 8 | The `check-call-window` step result | `outside_call_window` means the current time is outside the window *in the target's timezone*\n| 9 | The Inngest rate limit | 2 runs per `targetPhoneNumber` per 24 h — excess events are dropped, not queued\n|===\n\nA leaking Redis counter is the classic slow failure: dialing gradually reduces and then stops, with no\nerror anywhere. Compare `getCallTargetRuns(campaignId)` against `maxConcurrentCalls × 2`.\n\n== Targets are being skipped\n\nRead `CampaignTarget.lastError` and the workflow's `skipReason`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| `skipReason` / `lastError` | Cause\n\n| `dnc` | The number is on the DNC list\n| `prior_disposition` | A previous call to this number had `Do_Not_Call`, `Not_a_Seller` or `Amazon_Seller`\n| `lifecycle_stage` | The contact is a `customer` or `marketingqualifiedlead`\n| `invalid_e164` | The number does not match `/^\\+[1-9]\\d{1,14}$/`\n| `outside_call_window` | Outside the window in the target's timezone\n| `retell_error` | Retell rejected the call; the target is `failed` with the message in `lastError`\n| `Target phone number is blocked` | The registry row has `isBlocked = true`\n| `Target phone risk level (medium) must be below medium` | Registry `riskLevel` is `medium` or worse\n| `Target phone registry subscription mismatch` | The registry row belongs to another workspace\n|===\n\nTargets with `attempts = 9999` were permanently disqualified by pre-validation and will not return to the\npool even if their state is reset.\n\n== No emails are being sent\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| # | Check | If wrong\n\n| 1 | `Campaign.status` is `running` and `type` is `email` | The broadcast returns `throttled: true` with a status message\n| 2 | The `pg_cron` job exists | No tick, no sends\n| 3 | The broadcast returns `200`, not `401` | `INTERNAL_API_SECRET`\n| 4 | `throttled` and `throttleReason` in the response | `true` clears on its own; `false` needs an operator\n| 5 | At least one *active* variation exists | `\"No active email variations found\"` — `throttled: false`\n| 6 | At least one *active* sending account exists under an *active* domain | `\"No active email accounts on an active domain\"` — `throttled: false`\n| 7 | Accounts are under both their own and the parent domain's `dailyCap`, counting only sends whose `dailySentDate` is today (UTC) | `\"All email accounts over daily cap\"` — clears at 00:00 UTC, even if the reset job does not run\n| 8 | `countSentToday` against `maxEmailsPerDay` | Daily cap reached\n| 9 | Pending targets exist whose `target_emails` row is not suppressed, is past its cadence gap, and is not leased (`queuedAt` null or older than 2 days) | `\"No pending recipients available\"` — see <<stuck-leases>> if pending targets exist but are all leased\n| 10 | `emailSend` runs appear in Inngest | Check the skip reason on each\n|===\n\n`emailSend` skip reasons:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Reason | Meaning\n\n| `already_sent` | The target has left `pending` — a duplicate event, or a retry after success\n| `unsubscribed` | `newsletterOptOut`, `marketingOptOut` or `isBlocked` on the address\n| `newsletter_gap` | Fewer than `newsletterGapDays` (default 7) since the last marketing send to that address. The lock is released so it returns later\n| `variation_rate_limited` | Every active variation is over its hourly rate. The lock is released\n| `daily_limit_reached` | Campaign daily cap. The lock is released\n| `not_found` | Campaign or variation missing\n|===\n\n`newsletter_gap` is the one that most often looks like a bug: the campaign is healthy, the recipients are\neligible, but the address received a different campaign's email within the last week.\n\n[#stuck-leases]\n=== Pending targets hidden behind leases\n\nEach tick stamps `queuedAt` on the batch it leases, and the lease query skips rows leased within\n`EMAIL_TARGET_LOCK_WINDOW_SECONDS` (2 days). A tick that halts after leasing gives its leases back.\nBefore that fix, a run throttled by the daily cap left the batch leased, so a few hours of\nthrottled ticks could hide thousands of recipients for 2 days.\n\nCount leased pending targets:\n\n[source,sql]\n----\nSELECT count(*) FILTER (WHERE \"queuedAt\" >= NOW() - INTERVAL '2 days') AS leased,\n       count(*) FILTER (WHERE \"queuedAt\" IS NULL OR \"queuedAt\" < NOW() - INTERVAL '2 days') AS available\nFROM email_campaign_targets\nWHERE \"campaignId\" = '<campaign-id>' AND status = 'pending';\n----\n\nIf almost every pending target is leased and the timestamps are hours old, release them. The\n1-hour guard leaves leases from a live tick alone, and `emailSend` skips any target that has\nalready left `pending`, so a duplicate event cannot send twice:\n\n[source,sql]\n----\nUPDATE email_campaign_targets\nSET \"queuedAt\" = NULL\nWHERE \"campaignId\" = '<campaign-id>'\n  AND status = 'pending'\n  AND \"queuedAt\" < NOW() - INTERVAL '1 hour';\n----\n\n== No AI analysis is being produced\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Check | Detail\n\n| An analysis agent is configured | The campaign's `preferredAiAgentId`, else a workspace default. Neither → `\"No analysis AI agent configured\"`\n| The agent's `type` is `analysis` | Otherwise `\"Analysis agent not found or invalid\"`\n| `AI_GATEWAY_API_KEY` is set | Otherwise every run fails\n| The disconnection reason | Reasons in `BLACKLISTED_AI_ANALYSIS_REASONS` are skipped by design\n| Whether a double tap intervened | An eligible double tap returns *before* the analysis step\n| The `run-ai-analysis` step result | Transient errors (`EBUSY`, `ENOTFOUND`, `Cannot connect to API`, `Insufficient funds`) are recorded as `{ saved: false, error }`\n|===\n\n`Insufficient funds` means the AI Gateway account is out of credit — the error is treated as transient and\nsilently swallowed, so it produces missing analyses rather than alerts.\n\n== A sequence is stuck\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Check | Detail\n\n| `ContactSequence.status` | `pending` was never started; `paused` was stopped manually\n| `executionId` | Null means no run ever claimed it. Send `sequence/execute` via the start endpoint\n| The Inngest run | Look it up by `executionId`. A sleeping run is normal — a `wait` step or a gate retry delay\n| `ContactSequenceStep` logs | Each row's `status`, `outcome` and `response` show exactly what happened\n| Gate steps | `gateRetryCount` against `gateMaxRetries`. Exhausted retries with no `falseBranchStepIndex` *abandon* the sequence\n| Skipped actions | `no_contact_email`, `no_contact_phone`, `no_sms_number`, `no_sales_rep`, `no_hubspot_contact`, `already_called`, `invalid_crm_event_config`\n| BigQuery | A contact with no `hubspotContactId` fails every gate condition, by design\n|===\n\n`already_called` is intentional: a `call_task` step creates at most one task per enrollment, so a template\nthat loops back to a call step will skip it the second time.\n\n== CRM data is not appearing in HubSpot\n\n. Confirm the contact has a `hubspotContactId`. Every CRM publisher skips without one, deliberately.\n. Confirm `CRM_QUEUE_URL` and AWS credentials are set — publishers return `false` rather than throwing, so a\n  missing queue is silent.\n. Check for the publish in the logs.\n. Confirm call eligibility: duration ≥ 10 s, not voicemail, not screened, disposition in\n  `HUBSPOT_WHITE_LISTED_DISPOSITIONS`, and `isTestTarget` not true.\n. Check the *downstream consumer* — the application only enqueues. `Call.hubspotSyncStatus` is set by the\n  consumer, not by this application.\n\n== Webhooks are not arriving\n\n. Check the `WebhookEvent` table. A row means it arrived and the problem is in the handler.\n. No row → verify the provider's configured URL, and that the path is under `/api/webhooks/` (excluded from\n  middleware).\n. `401` from HubSpot → `HUBSPOT_WEBHOOK_CLIENT_SECRET` mismatch; that route rejects when unset.\n. A row exists but nothing changed → the event type may be unregistered. Only `call_analyzed` is registered\n  for Retell; `call_started` is stored and ignored. Five9 payloads are stored and never dispatched.\n. A Retell row with no effect → check for `campaignId` and `campaignTargetId` in `call.metadata`. Without\n  both, the handler logs and returns.\n\n== A report is empty or stale\n\n. Views prefixed `vm_` are *materialised* — check the refresh schedule before suspecting the route.\n. Date filters are Eastern Time. A `dateFrom` of today returns nothing before 00:00 ET, which is 05:00 UTC.\n. Views are managed in Supabase, not by Prisma migrations. A new environment needs them created by hand.\n. `phone_number_pickup_rate` joins `\"PhoneNumbers\"` — the quoted PascalCase name. An unquoted reference fails.\n\n== Duplicate sends or calls\n\nThe guards, in order of what to check:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Guard | Where\n\n| `WebhookEvent` unique on `(source, eventId)` | Duplicate deliveries\n| `Call.providerCallId` unique | Duplicate call rows\n| `callTarget` rate limit — 2 per number per 24 h | Repeat dialing\n| `callEndedAnalysis` rate limit — 1 per `callId` per 24 h | Repeat post-processing\n| `CampaignTarget.leaseExpiresAt` | Concurrent broadcast ticks\n| `EmailCampaignTarget.queuedAt` plus `FOR UPDATE SKIP LOCKED` | Concurrent email ticks\n| `emailSend` step 1 status check | Duplicate send events\n| `(subscriptionId, externalId)` unique on `Activity` and `Emails` | Duplicate logs\n|===\n\n`POST /api/webhooks/five9` is the known exception — it generates a random `eventId`, so replays create new\nrows.\n\n== Build and type errors\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Symptom | Fix\n\n| `@prisma/client did not initialize` | `pnpm prisma generate`\n| Prisma model types missing after a schema edit | `pnpm prisma generate` — the client lives in `prisma/generated`\n| Stale build after dependency changes | `rm -rf .next && pnpm install && pnpm build`\n| Missing types in a production build | Use `pnpm build:prod`, which generates the client first\n|===\n"},{"component":"eventlogger-rules","module":"ROOT","path":"index.adoc","title":"EventLogger Rules","description":"Event rule engine for the Payability platform: matches EventLogger events against configurable rules and runs their actions.","url":"https://docx.payability.com/eventlogger-rules/index.html","navOrder":0,"source":"= EventLogger Rules\n:description: Event rule engine for the Payability platform: matches EventLogger events against configurable rules and runs their actions.\n:keywords: eventlogger, event rules, rule engine, kinesis, aws sam, lambda\n\nimage::banner.svg[EventLogger Rules]\n\nEventLogger Rules evaluates every EventLogger event against the event rules configured in the CRM and runs\nthe matching rules' actions: sending emails, posting to Slack, creating CRM tasks and editing suppliers.\nIt also serves the REST API the CRM uses to manage rules, action templates and rule history, and it\nforwards CRM events to EventLogger.\n\nThe application is serverless: one AWS SAM stack per account with two Lambda functions, one for the API\nand one for background work, and no servers waiting for requests.\n\n[TIP]\n====\nNew here? Start with xref:architecture.adoc[System architecture], then\nxref:event-processing.adoc[Event processing and rules] for what happens to an event.\n====\n\n== What it does\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Capability | Where it lives\n\n| Run event rules for every event on the `em_events` Kinesis stream\n| `WorkerFunction` (`handlers/worker.py`), see xref:event-processing.adoc[]\n\n| Rule, action template and history management for the CRM\n| `ApiFunction` (`handlers/api.py`) behind `RulesApi`, see xref:api.adoc[]\n\n| Capture events from SDKs and tracking pixels (`/capture-message`, `/track`, `/tracking`)\n| `ApiFunction`, publishing to the `em_events` SNS topic\n\n| Flag rules that stopped firing (watchdog)\n| `WorkerFunction` (`handlers/watchdog.py`), every 15 minutes in production\n\n| Forward CRM events to EventLogger\n| `WorkerFunction` (`handlers/crm_event.py`), subscribed to `em_events_create_event`\n|===\n\n== Environments\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| Environment | AWS account | Stack | Deployed from\n\n| Staging | {stage-account} | `{dev-stack}` | `{dev-branch}`\n| Production | {prod-account} | `{prod-stack}` | `{prod-branch}`\n|===\n\nBoth run in `{aws-region}`. In production the API is published on `api.payability.com` under\n`{api-prefix}`, `/track` and `/tracking`.\n\n== Where to go next\n\n* xref:configuration.adoc[Configuration and secrets]: environment settings, `EnableTriggers`, the `{shared-secret}` secret\n* xref:development.adoc[Local development]: `make` targets, tests, running the API locally\n* xref:deployment.adoc[Build and deployment]: SAM build, GitHub Actions, OIDC follow-up\n* xref:operations:index.adoc[Operations]: monitoring, the Zappa to SAM cutover, rollback\n"},{"component":"eventlogger-rules","module":"ROOT","path":"architecture.adoc","title":"System architecture","description":"How the EventLogger Rules functions, event sources and data stores fit together.","url":"https://docx.payability.com/eventlogger-rules/architecture.html","navOrder":1,"source":"= System architecture\n:description: How the EventLogger Rules functions, event sources and data stores fit together.\n\nEventLogger Rules is one AWS SAM stack (`template.yaml`) per account with two Lambda functions built from\nthe same `src/` code: `ApiFunction` serves HTTP requests, and `WorkerFunction` handles every background\ntrigger (Kinesis, the CRM SNS topic and the watchdog schedule).\n\n== Architectural principles\n\nReuse, don't own, shared infrastructure::\nThe DynamoDB tables, the Kinesis stream and the SNS topics already exist and are shared with the rest of\nEventLogger. The template references them by name and never creates them, so deleting the stack can't\ntouch live data.\n\nOne switch for every consumer::\nThe Kinesis mapping, the watchdog schedule and the CRM SNS subscription are all controlled by the\n`EnableTriggers` parameter. A stack can be deployed with a working API while consuming nothing, which is how\na new deployment runs side by side with an old one. See xref:configuration.adoc#enable-triggers[EnableTriggers].\n\nNothing happens at import time::\nSettings, AWS clients and the Flask app are created on first use behind `lru_cache` accessors. The one\nexception is pyb-flask-utils, which loads `{shared-secret}` when its secret manager module is imported.\n\nRequests and background work are separate::\nThe API keeps a 30 s timeout and a role without Kinesis or Firehose access. Background work gets 900 s and\n1024 MB, and its errors and throttles never show up as API errors.\n\n== Component topology\n\n[source,mermaid]\n----\nflowchart LR\n  subgraph clients [\"Clients\"]\n    SDK([\"SDKs and tracking pixels\"])\n    CRM([\"CRM\"])\n  end\n\n  subgraph stack [\"SAM stack eventlogger-rules-dev / -prod\"]\n    API[\"RulesApi + ApiFunction\"]\n    WORKER[\"WorkerFunction\"]\n  end\n\n  subgraph old [\"Old-rules stack (release-P312)\"]\n    STAGE1[\"Stage 1: preprocess\"]\n  end\n\n  SNS[(\"SNS em_events\")]\n  KIN[(\"Kinesis em_events\")]\n  CRMTOPIC[(\"SNS em_events_create_event\")]\n  SCHED([\"Watchdog schedule\"])\n  DDB[(\"DynamoDB tables\")]\n  EL([\"EventLogger API\"])\n  EXT([\"Slack, email, tasks, suppliers\"])\n\n  SDK -->|\"/capture-message, /track\"| API\n  CRM -->|\"rules API\"| API\n  API -->|\"stage 0\"| SNS\n  API --> DDB\n  SNS --> STAGE1 --> KIN\n  KIN -->|\"batch of 500\"| WORKER\n  CRMTOPIC --> WORKER\n  SCHED --> WORKER\n  WORKER -->|\"rule actions\"| EXT\n  WORKER --> DDB\n  WORKER -->|\"CRM events\"| EL\n----\n\nStage 1 of the event pipeline (SNS to DynamoDB and Kinesis) belongs to the old-rules stack, which keeps\nrunning. This stack starts at the Kinesis stream: it doesn't write to Kinesis.\n\n== Functions\n\n`ApiFunction` (`handlers/api.py`) is behind `RulesApi` (`ANY /` and `ANY /{proxy+}`) with a 30 s timeout.\nAPI Gateway REST events are translated to WSGI for the Flask app. CORS, including preflight, is answered by\nflask-cors for `https://payability.com` and its subdomains only, see xref:security.adoc#cors[Security].\n\n`WorkerFunction` (`handlers/worker.py`) runs with 900 s and 1024 MB. Its entry point looks at the shape of the\nevent and hands it to the matching code:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Trigger | Recognised by | Runs\n\n| Kinesis `em_events[_staging]`, batch size 500\n| `Records[0].eventSource == \"aws:kinesis\"`\n| Stage 2 of the pipeline (`pipeline/stage_2_postprocess.py`)\n\n| SNS `em_events_create_event[_staging]`\n| `Records[0].EventSource == \"aws:sns\"`\n| `handlers/crm_event.py`: forwards allowed CRM events to EventLogger as instance 8001\n\n| Schedule, `rate(15 minutes)` in production and `rate(2 minutes)` in staging\n| `source == \"aws.events\"` and `detail-type == \"Scheduled Event\"`\n| `handlers/watchdog.py`\n|===\n\nAny other event raises an error. The Kinesis mapping and the SNS subscription exist, and the schedule is\n`ENABLED`, only with `EnableTriggers=true`.\n\n== Data stores\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Store | Use\n\n| `em_event_rules_v2` | Event rules: filters, actions, watchdog settings, last execution\n| `em_event_rule_logs_v2` | Rule execution history, one item per matched rule and event\n| `em_action_template` | Reusable action templates\n| `em_events` | Stored events, written by stage 2 and read by the tracking endpoints\n| SNS `em_events` | Entry point for new events (stage 0)\n| Firehose `em_events_es_staging` | Copy of every event, staging only\n| EventBridge bus `pyb-notifications` | Slack notifications from the Slack action\n|===\n\nTables carry the `_staging` suffix in the staging account. Items keep the format the production tables\nalready use: integer timestamps, a marshalled `data` attribute and `created_by_*` fields.\n\nSearch is disabled in both environments (`ES_EVENTS_URL` is empty), so `/events/stream` answers 503.\n\n== Request path\n\n[source,mermaid]\n----\nsequenceDiagram\n  autonumber\n  participant C as Client\n  participant G as api.payability.com\n  participant L as ApiFunction\n  participant F as Flask app\n  participant I as Internal API\n  participant D as DynamoDB\n\n  C->>G: GET /eventlogger_v2/event_rules/ (X-Session-Token, X-InstanceId)\n  G->>L: REST proxy event (path keeps the base path)\n  L->>F: WSGI request\n  F->>I: GET /token/validate\n  I-->>F: login_id\n  F->>F: check instance authorization\n  F->>D: query em_event_rules_v2\n  D-->>F: items\n  F-->>L: JSON response\n  L-->>G: statusCode, headers, body\n  G-->>C: 200\n----\n\nThe base path stays in the request path on the custom domain, which is why production routes are registered\nunder `{api-prefix}`. On an `execute-api` hostname the adapter adds the stage to the script root instead, so\ngenerated URLs work in both places.\n\n== Observability\n\n* Logs go to CloudWatch Logs, one log group per function: `/aws/lambda/<stack>-api` and\n  `/aws/lambda/<stack>-worker`. The template creates them and points each function at its group, so their\n  retention (90 days in production, 30 in staging) is set there and they are deleted with the stack.\n  `LOG_LEVEL` is `WARNING` in both environments.\n* Sentry is initialised for `production` and `staging`, tagged with `app=eventlogger-new-rules` and\n  `component` (`api` or `worker`). Worker events also carry `trigger` (`kinesis`, `crm_event` or\n  `watchdog`).\n* X-Ray tracing is active on every function.\n"},{"component":"eventlogger-rules","module":"ROOT","path":"event-processing.adoc","title":"Event processing and rules","description":"What happens to an event: the pipeline stages, rule matching, actions, history and the watchdog.","url":"https://docx.payability.com/eventlogger-rules/event-processing.html","navOrder":2,"source":"= Event processing and rules\n:description: What happens to an event: the pipeline stages, rule matching, actions, history and the watchdog.\n\n== Pipeline stages\n\nEvents move through three stages. This application owns stage 0 (through the API) and stage 2; stage 1\nbelongs to the old-rules stack.\n\n[cols=\"1,2,2,1\",options=\"header\"]\n|===\n| Stage | Input | Work | Owner\n\n| 0, initial\n| `/capture-message`, `/track`\n| Validate and publish the event to SNS `em_events`\n| `ApiFunction`\n\n| 1, preprocess\n| SNS `em_events`\n| Store in DynamoDB, forward to Kinesis `em_events`\n| old-rules stack\n\n| 2, postprocess\n| Kinesis `em_events`\n| Run event rules, copy to Firehose (staging only), store in `em_events`\n| `WorkerFunction`\n|===\n\n== Stage 2 in detail\n\n[source,mermaid]\n----\nsequenceDiagram\n  autonumber\n  participant K as Kinesis em_events\n  participant S as WorkerFunction\n  participant R as em_event_rules_v2\n  participant A as Action registry\n  participant H as em_event_rule_logs_v2\n  participant E as em_events\n\n  K->>S: batch of up to 500 records\n  loop each record\n    S->>R: scan all rules (no cache)\n    loop each active rule whose filters match\n      alt supplier status allows the rule\n        S->>A: run each action (email, Slack, task, supplier edit)\n        A-->>S: per-action log entry\n        S->>H: write history item with executed actions\n        S->>R: save last execution\n      else supplier status excludes the rule\n        S->>H: write history item without actions\n      end\n    end\n    S->>E: store the event\n  end\n----\n\nEach record runs every action of the stage in order (rules, Firehose outside production, DynamoDB). A failing\naction or an undecodable record is logged and skipped, so one bad event never blocks the shard. The\nfunction only fails the whole batch on an unexpected error outside that loop, in which case Lambda retries\nthe batch.\n\nRules are read on every event, so edits in the CRM apply to the next event without a deploy.\n\n== Rule matching\n\nA rule matches an event when:\n\n. the rule is active, and\n. every filter of the rule matches. A filter has a `key` (a selector into the event), an `operator`, a\n  `type` and a `value`.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Operator | Type | Matches when\n\n| `eq` | `keyword` | the event value equals the rule value\n| `contains` | `text` | the rule value is contained in the event value, case-insensitive\n|===\n\n`/constants/rules` lists the selectors and operators the CRM can offer, and `/simulate/filters` and\n`/simulate/rules` evaluate filters or rules against a sample event without running actions.\n\n=== Supplier status gate\n\nWhen the event targets a supplier, the rule runs only if the supplier's current status (read from the\ninternal API) is one of the rule's `executable_supplier_statuses`. If the supplier can't be found, the rule\nruns. A rule that matches but is excluded by status still gets a history entry, without executed actions.\n\n== Actions\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Slug | Name | What it does\n\n| `send_email` | Send email | Renders a template through the messaging service and sends it, optionally to the supplier and CC receivers\n| `send_to_slack` | Send to Slack | Publishes a notification event to the EventBridge bus `pyb-notifications`\n| `create_task` | Create task | Creates a CRM task through the action tools API\n| `edit_supplier` | Edit supplier | Updates supplier fields through the internal API\n|===\n\nEvery executed action appends an entry to the rule's history item, including errors, so a failing action is\nvisible in `/event_rules_history/...` without searching logs.\n\n== Watchdog\n\nRules can have a watchdog interval. On every scheduled run, `WorkerFunction` scans all rules and sets each rule's\n`alert` flag to true when the rule has an interval and hasn't fired within it, and to false otherwise. The\nCRM shows the flag on the rule.\n\n== CRM events\n\n`WorkerFunction` also receives events published to `em_events_create_event` (EventBridge-style `detail`\ninside an SNS message). It accepts only these values, as defined in the EventLogger Python SDK:\n\n* event types: `analitics` (sic), `event`, `underwriting-event`, `messaging-result`, `messaging-log`, `cli-event`\n* target types: `supplier`, `marketplace`, `compliance`, `crm-task`, `general`\n\nIt then captures the event in EventLogger through the SDK as instance 8001. From there it enters the pipeline\nat stage 0 like any other event.\n"},{"component":"eventlogger-rules","module":"ROOT","path":"api.adoc","title":"REST API","description":"Endpoints served by ApiFunction, their authentication and where they are published.","url":"https://docx.payability.com/eventlogger-rules/api.html","navOrder":3,"source":"= REST API\n:description: Endpoints served by ApiFunction, their authentication and where they are published.\n\n`ApiFunction` serves a flask-restx API plus a few plain Flask views. The current contract is the set of\nendpoints listed below; the flask-restx Swagger document and UI are disabled.\n\n== Base URLs\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Environment | Base URL\n\n| Production (custom domain) | `https://api.payability.com{api-prefix}`\n| Production (direct) | `https://<RulesApiId>.execute-api.us-east-1.amazonaws.com/prod{api-prefix}`\n| Staging | `https://<RulesApiId>.execute-api.us-east-1.amazonaws.com/stage`\n|===\n\nRoutes live under `API_BASE_PREFIX` (`{api-prefix}` in production, empty in staging). `/track` and\n`/tracking/...` are also served without the prefix, for the `track` and `tracking` base paths on\n`api.payability.com` that older clients use. `RulesApiId` is a stack output.\n\n== Authentication\n\nEvery method of `RulesApi` goes through `PayabilityAPIGatewayAuthorizer` first, the same Lambda request\nauthorizer (`pyb-api-gateway-authorizer:live`) the Zappa API used. It is keyed on the `Host` header and\ncaches its decision for an hour. The deployed version allows every request, so today it changes nothing;\nit is there so the API picks up any rule added to it later.\n\nAuthentication of the caller comes from pyb-flask-utils and differs per endpoint group:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoints | Authentication\n\n| flask-restx namespaces (rules, templates, history, constants, simulate, trigger, events)\n| `X-Session-Token`, validated against the internal API (`/token/validate`), plus `X-InstanceId`, which the\n  caller must be authorised for\n\n| `/capture-message`\n| Instance authentication (`requires_auth`)\n\n| `/es/<path>`, `/es-changelog`\n| RSA-signed JWT (`requires_auth_jwt_rsa`, key `AUTH_JWT_RSA_PUBLIC_KEY`)\n\n| `/track`, `/tracking/`, `/tracking/history/`\n| None: used by the JavaScript SDK's tracking pixel\n|===\n\n== Endpoints\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Path (under the base URL) | Methods | Purpose\n\n| `/event_rules/` | GET, POST | List and create rules (at most `EVENT_RULE_LIMIT` rules in total, 100)\n| `/event_rules/<uuid>/` | GET, PUT, PATCH, DELETE | Read, replace, update or delete a rule\n| `/action_templates/` | GET, POST | List and create action templates\n| `/action_templates/<uuid>/` | GET, PUT, DELETE | Read, replace or delete an action template\n| `/event_rules_history/event_rule/<uuid>/` | GET | Executions of one rule\n| `/event_rules_history/target/<uuid>/` | GET | Executions for one target (for example a supplier)\n| `/constants/rules`, `/constants/actions`, `/constants/statuses` | GET | Selectors, operators, actions and statuses for the CRM rule editor\n| `/simulate/filters`, `/simulate/rules` | POST | Evaluate filters or rules against a sample event, without running actions\n| `/trigger/rules` | POST | Run the rules for an event now, including their actions\n| `/events/stream` | POST | Event search; answers 503 while search is disabled\n| `/capture-message` | GET, POST | Capture an event (stage 0)\n| `/track` | POST | Capture a tracking event (stage 0)\n| `/tracking/`, `/tracking/history/` | GET | Tracking data and first contact for a target or owner\n| `/es/<path>`, `/es-changelog` | GET, POST, PUT | Search passthrough and object changelog\n|===\n\n[WARNING]\n====\n`/trigger/rules`, `/capture-message` and `/track` have side effects in the target environment: they publish\nreal events or run real actions. Use `/simulate/...` to test rules.\n====\n\n== Response format\n\nResponses use the pyb-flask-utils envelope:\n\n[source,json]\n----\n{\n  \"data\": {},\n  \"meta\": {\"errors\": []},\n  \"status_code\": 200\n}\n----\n\nValidation errors and authentication failures put a list of `{\"message\", \"type\"}` objects in\n`meta.errors`.\n"},{"component":"eventlogger-rules","module":"ROOT","path":"repository-layout.adoc","title":"Repository layout","description":"Where things live in the repository and how the source tree maps to the Lambda artifact.","url":"https://docx.payability.com/eventlogger-rules/repository-layout.html","navOrder":4,"source":"= Repository layout\n:description: Where things live in the repository and how the source tree maps to the Lambda artifact.\n\n[source]\n----\n.\n├── src/                    application code; also the root of the Lambda artifact\n├── tests/                  pytest suite (unit/ and integration/), AWS mocked with moto\n├── scripts/                one-off operational scripts\n├── docs/                   this Antora component\n├── template.yaml           SAM template: RulesApi, API and worker functions, IAM, environment mapping\n├── samconfig.toml          SAM config environments: dev and prod\n├── Makefile                every developer, CI and SAM build command\n├── pyproject.toml, uv.lock dependencies (uv) and tool configuration (ruff, complexipy, mypy, pytest, coverage)\n└── .github/                CI and deploy workflows, shared setup action\n----\n\n== Source packages\n\n`src/` is the import root: modules are imported by bare name (`from settings import get_settings`), both in\nLambda and in tests. `make check-names` fails if an installed distribution shadows one of these names.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Package | Responsibility\n\n| `handlers/` | Lambda entry points `api` (API Gateway REST to WSGI) and `worker` (dispatches Kinesis, SNS and schedule events), plus the `watchdog` and `crm_event` jobs the worker runs\n| `web/` | Flask app factory (`create_app`), flask-restx namespaces in `web/api/`, plain blueprints in `web/views/`\n| `pipeline/` | Stage, stream and action classes: `stage_0_initial`, `stage_1_preprocess`, `stage_2_postprocess`\n| `services/` | Rule engine, rule, template and history services, events service\n| `domain/` | Event and rule models, operators, selectors, resolvers, action registry and action fields\n| `persistence/` | DynamoDB access compatible with the existing tables; table schemas used by the moto tests\n| `integrations/` | AWS clients (SNS, Kinesis, Firehose, EventBridge), internal Payability APIs, Segment, search\n| `settings.py` | Environment settings (`get_settings()`) and the `{shared-secret}` secret (`get_secret()`)\n| `observability.py` | Logging and Sentry, configured once per cold start\n|===\n\nDependencies point inwards: `handlers` and `web` use `services` and `pipeline`, which use `domain`,\n`persistence` and `integrations`. `domain` doesn't import from `web` or `handlers`.\n\n== Tests\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Path | Scope\n\n| `tests/unit/` | Models, operators, utils, pipeline building blocks, stage 1 parsing\n| `tests/integration/` | API namespaces, CRUD against moto DynamoDB, the rule engine with mocked HTTP, handlers, route layout\n| `tests/conftest.py` | Test environment (no AWS access, secrets from environment variables) and shared fixtures\n| `tests/helpers.py`, `tests/fakes.py` | moto resources, mocked authentication, sample rules and events\n|===\n\n`tests/` is a regular package (it has `__init__.py`) because pyb-flask-utils and the EventLogger SDK both\ninstall a top-level `tests` package into site-packages.\n"},{"component":"eventlogger-rules","module":"ROOT","path":"configuration.adoc","title":"Configuration and secrets","description":"Where each setting comes from: samconfig.toml, the template's environment mapping, environment variables and the common/crm secret.","url":"https://docx.payability.com/eventlogger-rules/configuration.html","navOrder":5,"source":"= Configuration and secrets\n:description: Where each setting comes from: samconfig.toml, the template's environment mapping, environment variables and the common/crm secret.\n\nConfiguration has three layers:\n\n. `samconfig.toml` picks the stack, the environment and whether triggers are on.\n. `template.yaml`, mapping `EnvironmentSettings`, holds per-environment values and passes them to every\n  function as environment variables.\n. The `{shared-secret}` secret in Secrets Manager holds credentials. It is shared with other applications.\n\nChanging any of them is a pull request; the deploy workflow applies it.\n\n== samconfig.toml\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Setting | `[dev]` | `[prod]`\n\n| `stack_name` | `{dev-stack}` | `{prod-stack}`\n| `EnvironmentName` | `stage` | `prod`\n| `EnableTriggers` | `true` | `false` until the cutover\n|===\n\nBoth deploy to `{aws-region}` with `resolve_s3 = true` (the SAM managed artifact bucket) and\n`CAPABILITY_IAM`. `[dev.local_start_api]` sets `EnableTriggers=false` for `make run-local`.\n\n[#enable-triggers]\n== EnableTriggers\n\n`EnableTriggers` controls every event consumer of the stack:\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Resource | `true` | `false`\n\n| Kinesis event source mapping | created | not created\n| CRM SNS subscription and invoke permission | created | not created\n| Watchdog schedule | `ENABLED` | `DISABLED`\n| `RulesApi` | serves requests | serves requests\n|===\n\n[source,mermaid]\n----\nstateDiagram-v2\n  direction LR\n  Dark: EnableTriggers=false (API only)\n  Live: EnableTriggers=true (API + consumers)\n  [*] --> Dark: first deploy\n  Dark --> Live: old consumers stopped, start position set\n  Live --> Dark: rollback\n----\n\nTurn it on only once the deployment it replaces no longer consumes the same sources. Otherwise every rule\naction runs twice. See xref:operations:cutover.adoc[].\n\n== Kinesis start position\n\n`EnvironmentSettings` sets `KinesisStartingPosition` (`LATEST`, `AT_TIMESTAMP` or `TRIM_HORIZON`) and\n`KinesisStartingTimestamp` (Unix seconds, used with `AT_TIMESTAMP`). They are read only when the mapping is\ncreated, that is when `EnableTriggers` goes from `false` to `true`.\n\n[IMPORTANT]\n====\nDon't change them while the mapping exists. The change replaces the mapping, and Lambda allows only one\nmapping per function and stream, so the deploy fails and rolls back. To restart at another position, deploy\nwith `EnableTriggers=false`, then with `true` and the new values.\n====\n\n== Environment variables\n\nSet on every function from `EnvironmentSettings`:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Variable | Production | Staging\n\n| `ENV_NAME` | `production` | `staging`\n| `API_BASE_PREFIX` | `{api-prefix}` | _empty_\n| `PAYABILITY_INTERNAL_API_URL` | `https://api.payability.com/v1/internal` | `https://staging.dev.payability.com/database_v2/internal`\n| `ACTION_TOOLS_URL` | `https://api.payability.com/actionstools` | staging action tools API\n| `MESSAGING_URL` | `https://api.payability.com/messaging` | staging messaging API\n| `EVENTLOGGER_URL` | `https://api.payability.com/eventlogger/` | staging EventLogger API\n| `SNS_TOPIC_EVENTS` | `em_events` topic ARN | `em_events_staging` topic ARN\n| `ES_EVENTS_URL` | _empty_ (search off) | _empty_ (search off)\n| `EVENT_RULE_LIMIT` | `100` | `100`\n| `LOG_LEVEL` | `WARNING` | `WARNING`\n| `SENTRY_DSN` | shared DSN | shared DSN\n| `AWS_SECRETS_MANAGER` | `True` | `True`\n|===\n\n`settings.get_settings()` reads them once per process. Trailing slashes on URLs are optional.\n\n== The common/crm secret\n\n`settings.get_secret(key)` reads a key from `{shared-secret}`. With `AWS_SECRETS_MANAGER=False` (local\ndevelopment, tests), pyb-flask-utils reads the same key from an environment variable instead.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Key | Used for\n\n| `AUTH_JWT_RSA_PUBLIC_KEY` | Verifying RSA-signed JWTs on `/es/...` and `/es-changelog`\n| `INTERNAL_JWT` | Calling the internal API and the action tools API\n| `8001` | EventLogger key of instance 8001, used for CRM events by `WorkerFunction`\n| `SEGMENT_WRITE_KEY` | Segment analytics\n| `EVENTLOGGER_URL` | Optional; overrides the environment variable of the same name\n|===\n\nThe secret is cached per process, so a new value reaches a function on its next cold start.\n\n`put-secret-value` replaces the whole secret; merge new keys into the current value instead. The command is\nin xref:operations:cutover.adoc#secrets[the cutover runbook].\n"},{"component":"eventlogger-rules","module":"ROOT","path":"development.adoc","title":"Local development","description":"Setting up the project, the make targets, tests and running the API locally.","url":"https://docx.payability.com/eventlogger-rules/development.html","navOrder":6,"source":"= Local development\n:description: Setting up the project, the make targets, tests and running the API locally.\n\n== Requirements\n\n* https://docs.astral.sh/uv/[uv] (Python {python-version} is installed by uv if missing)\n* GNU make\n* Read access to the private repositories pinned in `pyproject.toml`: `pybFlaskUtils`, `EventLoggerPySDK`\n  and `MessagingEmail`\n* For SAM targets: the AWS SAM CLI. For `make run-local`: Docker as well.\n\n== Quick start\n\n[source,bash]\n----\nmake install   # .venv from uv.lock, plus pre-commit hooks (ruff format, lint, complexity)\nmake check     # lint (incl. complexity), typecheck, name-clash guard, tests\n----\n\nEvery command CI runs is a `make` target, so a green `make check` locally means a green CI job, apart from\n`sam-validate` and the lockfile check (`uv lock --check`).\n\n== Make targets\n\n[source]\n----\ninclude::example$make-help.txt[]\n----\n\n`ENV` (`dev` or `prod`) selects the `samconfig.toml` environment for SAM targets, and `AWS_PROFILE` is\npassed to SAM as `--profile`.\n\n== Complexity limits\n\n`make lint` fails when a function in `src/`, `tests/` or `scripts/` is too complex. Two measures are checked,\nboth configured in `pyproject.toml`:\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Measure | Limit | Tool and what it counts\n\n| Cyclomatic complexity\n| 10\n| ruff `C901` (`[tool.ruff.lint.mccabe]`): the number of independent paths, one per branch or loop\n\n| Cognitive complexity\n| 15\n| https://github.com/rohaquinlop/complexipy[complexipy] (`[tool.complexipy]`): branches and loops weighted by\n  how deeply they are nested, so flat code scores low even with many cases\n|===\n\nTo see the scores, run `uv run complexipy --sort desc`. When a function goes over, flatten it (early\nreturns, a lookup table instead of an `if`/`elif` chain, small helpers) rather than raising the limit.\n\n== Docstrings\n\n`make lint` also fails when a module, class or public function in `src/` or `scripts/` has no docstring\n(ruff `D1`; tests, magic methods and `+__init__+` are exempt). Write what the code is responsible for and what\ncallers need to know, such as what it raises or returns when something is missing, rather than restating\nthe name.\n\n== Dependencies\n\n* Add or change a dependency in `pyproject.toml`, then run `make lock` and commit `uv.lock`.\n* `make upgrade` re-resolves everything to the newest allowed versions.\n* The private libraries are pinned to git tags in `[tool.uv.sources]`.\n\n== Tests\n\n[source,bash]\n----\nmake test        # pytest\nmake test-cov    # with coverage (terminal + coverage.xml)\nuv run --frozen pytest tests/integration/test_handlers.py   # a subset\n----\n\nTests need no AWS access or network. `tests/conftest.py` sets fake AWS credentials and\n`AWS_SECRETS_MANAGER=False` before anything is imported, moto's `mock_aws` provides DynamoDB, SNS and Kinesis,\nand HTTP calls to internal APIs are mocked with `requests-mock`.\n\n== Running the API locally\n\n[source,bash]\n----\nmake run-local AWS_PROFILE=dev\n----\n\nThis builds the functions and serves `ApiFunction` on `http://127.0.0.1:3000` with `sam local start-api`,\nusing the staging settings with triggers off. It reads `{shared-secret}` and the `_staging` tables of the\nstaging account through `AWS_PROFILE`, so treat it as staging.\n\n* The first request takes a while: SAM pulls the Lambda image and starts a container.\n* SAM logs `pyb-api-gateway-authorizer not found` on each request. The authorizer lives in another stack,\n  so it is skipped locally and requests go straight to the app.\n* Run `make sam-build` again after code changes.\n\n== Scripts\n\n`scripts/` holds one-off operational scripts (rule migration, history listing, test events). Tokens and keys\ncome from environment variables, for example:\n\n[source,bash]\n----\nEVENTLOGGER_SESSION_TOKEN=... uv run python scripts/list_rules_history.py\n----\n"},{"component":"eventlogger-rules","module":"ROOT","path":"deployment.adoc","title":"Build and deployment","description":"How the Lambda artifact is built, how GitHub Actions deploys each environment, and the OIDC follow-up.","url":"https://docx.payability.com/eventlogger-rules/deployment.html","navOrder":7,"source":"= Build and deployment\n:description: How the Lambda artifact is built, how GitHub Actions deploys each environment, and the OIDC follow-up.\n\n== Branches and stacks\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Branch | Stack | Account\n\n| `{dev-branch}` | `{dev-stack}` | staging, {stage-account}\n| `{prod-branch}` | `{prod-stack}` | production, {prod-account}\n|===\n\nA push to either branch deploys its stack. Pull requests to them run CI only. Changes to\n`{prod-branch}` go through reviewed pull requests.\n\n== Build\n\n`sam build` uses `BuildMethod: makefile`: for each function it calls the Makefile target\n`build-<LogicalId>` with `ARTIFACTS_DIR` set. `build-ApiFunction` and `build-WorkerFunction` run the same\nrecipe:\n\n. Export the runtime dependencies from `uv.lock` (`uv export --frozen --no-dev`).\n. Install them into the artifact for `x86_64-manylinux2014` and Python {python-version}\n  (`uv pip install --python-platform ... --target`), so a build on macOS still produces Linux wheels.\n. Fail if a macOS binary (`*darwin*.so`, `*.dylib`) ended up in the artifact.\n. Copy `src/` into the artifact, so handlers are `handlers.<module>.lambda_handler`.\n\n`samconfig.toml` builds in source (`build_in_source = true`), cached and in parallel. Each artifact is about\n51 MB unzipped.\n\n== Deploy\n\n`make deploy ENV=dev|prod` runs `sam build` and `sam deploy --config-env <env>`. The same command runs in CI\nand, for emergencies, from a laptop:\n\n[source,bash]\n----\nmake deploy ENV=dev AWS_PROFILE=dev\n----\n\n== GitHub Actions\n\n[source,mermaid]\n----\nflowchart LR\n  PR[\"Pull request to develop_new_rules or new-rules-P312\"] --> CI\n  PUSHDEV[\"Push to develop_new_rules\"] --> DEPLOY[\"deploy.yml\"]\n  PUSHPROD[\"Merge to new-rules-P312\"] --> DEPLOY\n  DEPLOY --> CI[\"ci.yml: lock check, lint, typecheck, check-names, test-cov, sam-validate\"]\n  CI -->|develop_new_rules| DEV[\"_deploy.yml env=dev\"]\n  CI -->|new-rules-P312| PROD[\"_deploy.yml env=prod\"]\n  DEV --> DEVSTACK[(\"eventlogger-rules-dev\")]\n  PROD --> PRODSTACK[(\"eventlogger-rules-prod\")]\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| File | Role\n\n| `.github/workflows/ci.yml`\n| Runs on pull requests, on demand and as a reusable workflow: `uv lock --check`, `make install`, `lint`,\n  `typecheck`, `check-names`, `test-cov` (uploads `coverage.xml`), `sam-validate`\n\n| `.github/workflows/deploy.yml`\n| Runs on pushes to the two branches and on demand: calls CI, then `_deploy.yml` for the branch's environment\n\n| `.github/workflows/_deploy.yml`\n| Reusable deploy job: checks the AWS account ID, runs `make deploy ENV=<env>`, writes the stack outputs to the\n  job summary\n\n| `.github/actions/setup`\n| Shared setup: Python from `.python-version`, a pinned uv with its cache, and git access to the private\n  dependencies through `PYB_GITHUB_TOKEN`\n|===\n\nDeploy jobs run in the GitHub environment of the same name. `concurrency: deploy-<env>` allows one deploy per\nenvironment at a time and never cancels a running one. All actions are pinned by commit SHA and workflows\nonly get `contents: read`.\n\n=== Secrets\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Secret | Use\n\n| `PYB_GITHUB_TOKEN` | Read access to `pybFlaskUtils`, `EventLoggerPySDK`, `MessagingEmail`\n| `DEV_AWS_ACCESS_KEY_ID`, `DEV_AWS_SECRET_ACCESS_KEY` | Deploys to {stage-account}\n| `PROD_AWS_ACCESS_KEY_ID`, `PROD_AWS_SECRET_ACCESS_KEY` | Deploys to {prod-account}\n|===\n\nThese are organisation secrets. The deploy keys need to create and update CloudFormation stacks, IAM roles\n(`CAPABILITY_IAM`), Lambda functions and event source mappings, API Gateway, EventBridge rules, SNS\nsubscriptions and the SAM managed S3 bucket. They also need `lambda:AddPermission` and\n`lambda:RemovePermission` on `pyb-api-gateway-authorizer:live`, because the stack grants `RulesApi` the right\nto invoke that shared authorizer.\n\n== Follow-up: OIDC deploy roles\n\nThe deploy job still uses long-lived organisation access keys. Replace them with short-lived credentials from\nGitHub's OIDC provider:\n\n. In each account, create the IAM OIDC provider `token.actions.githubusercontent.com` with audience\n  `sts.amazonaws.com`, if it doesn't exist yet.\n. Create a deploy role per account, for example `github-eventlogger-rules-deploy`, that trusts only this\n  repository's environment. Because the deploy job declares `environment:`, the token's `sub` claim is\n  environment-based:\n+\n[source,json]\n----\n{\n  \"Effect\": \"Allow\",\n  \"Principal\": {\"Federated\": \"arn:aws:iam::625663093496:oidc-provider/token.actions.githubusercontent.com\"},\n  \"Action\": \"sts:AssumeRoleWithWebIdentity\",\n  \"Condition\": {\n    \"StringEquals\": {\n      \"token.actions.githubusercontent.com:aud\": \"sts.amazonaws.com\",\n      \"token.actions.githubusercontent.com:sub\": \"repo:Payability/EventLoggerApp:environment:prod\"\n    }\n  }\n}\n----\n+\nUse `environment:dev` and the {stage-account} provider for the dev role. Limit its permissions to the\n`eventlogger-rules-*` stacks, their roles and functions, `RulesApi` and the SAM managed bucket.\n. In `_deploy.yml`, add `permissions: id-token: write` to the deploy job and replace the access key inputs of\n  `configure-aws-credentials` with `role-to-assume: arn:aws:iam::<account>:role/github-eventlogger-rules-deploy`.\n. Remove the `AWS_*` secrets from `deploy.yml` and `_deploy.yml`.\n\n== Legacy CodeBuild project\n\nThe production account still has the CodeBuild project `EventLoggerApp` from the Zappa era. Its webhook\nbuilds every push with the pushed commit's `buildspec.yml`, which the new branches don't have. Exclude them\nbefore the first push: see xref:operations:cutover.adoc#codebuild[the cutover runbook].\n"},{"component":"eventlogger-rules","module":"ROOT","path":"security.adoc","title":"Security","description":"Authentication, IAM boundaries, secrets handling and supply-chain controls.","url":"https://docx.payability.com/eventlogger-rules/security.html","navOrder":8,"source":"= Security\n:description: Authentication, IAM boundaries, secrets handling and supply-chain controls.\n\n== Authentication\n\nAll rule management endpoints require a Payability session token and an instance the caller is authorised\nfor. Tracking endpoints are public by design. In front of the app, API Gateway runs the shared\n`PayabilityAPIGatewayAuthorizer`, which currently allows every request. The per-endpoint matrix is in xref:api.adoc#authentication[REST\nAPI].\n\n[#cors]\n=== Cross-origin requests\n\nBrowsers may call the API only from `https://payability.com` and its subdomains (for example\n`crm.payability.com` or `staging.dev.payability.com`). The rule is `CORS_ALLOWED_ORIGINS` in `src/settings.py`\nand applies to every route, including the `@cross_origin` views. A matching origin is echoed back in\n`Access-Control-Allow-Origin` (with `Vary: Origin`); `/track` and `/tracking` also allow credentials, for the\n`pybUserID` cookie. Any other origin, plain `http`, or a look-alike host such as `payability.com.example.com`\ngets no CORS headers, so the browser blocks the response.\n\nCORS only restricts browsers. Internal services calling server to server send no `Origin` header and are not\naffected; what protects the API from them is the authentication above.\n\n== IAM\n\nEach function has its own role. Resources are named exactly, with the environment's table suffix:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Function | Allowed\n\n| `ApiFunction`\n| CRUD on `em_event_rules_v2`, `em_action_template`, `em_event_rule_logs_v2`; read on `em_events`; publish to\n  SNS `em_events`; `PutEvents` on `pyb-notifications`; read `{shared-secret}`\n\n| `WorkerFunction`\n| CRUD on `em_event_rules_v2`, `em_event_rule_logs_v2`; write to `em_events`; read Kinesis `em_events`;\n  write to Firehose `em_events_es<suffix>`; `PutEvents` on `pyb-notifications`; read `{shared-secret}`.\n  The CRM events go to EventLogger over HTTPS and need no IAM permission.\n|===\n\nAll functions can also write CloudWatch Logs and X-Ray traces.\n\n== Secrets\n\n* Credentials live only in `{shared-secret}` and are read at runtime. Nothing secret is in the template,\n  `samconfig.toml` or environment variables.\n* Scripts read tokens and keys from environment variables; none are committed.\n* Tests use fake values set in `tests/conftest.py`.\n\n== Supply chain\n\n* Dependencies are locked in `uv.lock`; CI fails if it is out of date with `pyproject.toml`.\n* The private libraries are pinned to git tags.\n* GitHub Actions are pinned by commit SHA; workflows run with `contents: read`.\n* The deploy job checks the AWS account ID of its credentials before deploying.\n"},{"component":"eventlogger-rules","module":"ROOT","path":"docs-contributing.adoc","title":"Writing these docs","description":"How this Antora component is structured, consumed and extended.","url":"https://docx.payability.com/eventlogger-rules/docs-contributing.html","navOrder":9,"source":"= Writing these docs\n:description: How this Antora component is structured, consumed and extended.\n\n`docs/` is an Antora documentation component, not a standalone site. It has no playbook of its own: the\ncentral documentation project, `Payability/pyb-technical-docs`, consumes it as a content source and builds\nit with the other components.\n\n== Consuming this component\n\nAdd the repository to the central playbook's content sources:\n\n[source,yaml]\n----\ncontent:\n  sources:\n    - url: https://github.com/Payability/EventLoggerApp.git\n      branches: new-rules-P312\n      start_path: docs\n----\n\n`start_path: docs` is what makes Antora find `docs/antora.yml`. Add the component to the hub's navigation\nand start page with `xref:eventlogger-rules::index.adoc[EventLogger Rules]`.\n\n== Component descriptor\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Key | Value\n\n| `name` | `eventlogger-rules`, used in every cross-component xref\n| `title` | `EventLogger Rules`, shown in the component selector\n| `version` | `~`: unversioned, so URLs have no version segment\n| `start_page` | `ROOT:index.adoc`\n| `nav` | One entry per module navigation file, in menu order\n| `asciidoc.attributes` | Shared facts (accounts, stacks, branches, secret name), written once\n|===\n\nReferencing this component from another one:\n\n[source,asciidoc]\n----\nxref:eventlogger-rules::architecture.adoc[EventLogger Rules architecture]\nxref:eventlogger-rules:operations:cutover.adoc[Cutover runbook]\n----\n\n== Modules\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Module | Scope\n\n| `ROOT` | The application: architecture, event processing, API, configuration, development, deployment, security\n| `operations` | Runbooks: monitoring, the Zappa to SAM cutover, rollback, decommissioning\n|===\n\n== Authoring rules\n\n* One topic per page; start each page with a title and a `:description:`.\n* Use the attributes from `antora.yml` (`{prod-account}`, `{prod-stack}`, ...) in prose instead of repeating\n  values. Attributes are not substituted inside source blocks, so commands there spell values out.\n* Write diagrams as Mermaid in a source block. The hub renders `[source,mermaid]` blocks; a `[mermaid]`\n  literal block is shown as plain text.\n+\n[source,asciidoc]\n....\n[source,mermaid]\n----\nflowchart LR\n  A --> B\n----\n....\n* Images go in `modules/<module>/images/`, files included verbatim in `modules/<module>/examples/`.\n* `examples/make-help.txt` is generated: run `make docs-help` after changing Makefile targets.\n* Link between pages with `xref:`, never with URLs to the built site.\n"},{"component":"eventlogger-rules","module":"operations","path":"operations:index.adoc","title":"Operations","description":"Monitoring the stacks and the runbooks for cutover, rollback and decommissioning.","url":"https://docx.payability.com/eventlogger-rules/operations/index.html","navOrder":10,"source":"= Operations\n:description: Monitoring the stacks and the runbooks for cutover, rollback and decommissioning.\n\n== Runbooks\n\n* xref:cutover.adoc[]: moving from the Zappa deployments to the SAM stacks\n* xref:rollback.adoc[]: going back to Zappa\n* xref:decommission.adoc[]: removing Zappa after the soak period\n\n== Health checks\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Check | How\n\n| Stream consumer keeps up\n| `IteratorAge` of `WorkerFunction` stays at a few seconds. A growing value means the function is\n  failing whole batches or is too slow.\n\n| Rules run\n| New items in `em_event_rule_logs_v2` for recent events; `/event_rules_history/event_rule/<uuid>/` for one rule\n\n| Watchdog runs\n| `Invocations` of the `WorkerFunctionWatchdogSchedule` EventBridge rule once per period, `FailedInvocations`\n  at 0, and no Sentry errors tagged `trigger:watchdog`\n\n| CRM events arrive\n| `NumberOfNotificationsFailed` of `em_events_create_event` stays at 0 (the topic carries about 8 messages\n  a day), and no Sentry errors tagged `trigger:crm_event`\n\n| API healthy\n| `5XXError` and `Latency` of `RulesApi`; an authenticated `/constants/statuses` answers 200\n\n| Errors\n| Sentry, `app:eventlogger-new-rules`, filtered by `component` (`api`, `worker`) and `trigger`\n|===\n\n== Useful commands\n\n[source,bash]\n----\n# Stack outputs: API id and URL, function ARNs\naws cloudformation describe-stacks --stack-name eventlogger-rules-prod --query 'Stacks[0].Outputs' --profile prod\n\n# Follow a function's logs\nsam logs --stack-name eventlogger-rules-prod --name WorkerFunction --tail --profile prod\n\n# Kinesis mapping state and start position\naws lambda list-event-source-mappings --function-name <WorkerFunctionArn> \\\n  --query 'EventSourceMappings[].[UUID,State,StartingPosition]' --profile prod\n\n# Consumer lag over the last 15 minutes (macOS date syntax)\naws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name IteratorAge \\\n  --dimensions Name=FunctionName,Value=<WorkerFunctionName> --statistics Maximum --period 60 \\\n  --start-time \"$(date -u -v-15M +%FT%TZ)\" --end-time \"$(date -u +%FT%TZ)\" --profile prod\n----\n\nUse `dev` and `eventlogger-rules-dev` for staging.\n"},{"component":"eventlogger-rules","module":"operations","path":"operations:cutover.adoc","title":"Zappa to SAM cutover","description":"Moving the new-rules app from the Zappa deployments to the SAM stacks without skipping or repeating events.","url":"https://docx.payability.com/eventlogger-rules/operations/cutover.html","navOrder":11,"source":"= Zappa to SAM cutover\n:description: Moving the new-rules app from the Zappa deployments to the SAM stacks without skipping or repeating events.\n\nThis runbook moves the new-rules app from the Zappa deployments (Python 3.8) to the SAM stacks.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| | Old (Zappa) | New (SAM)\n\n| Staging ({stage-account}) | `eventloggerapp-test-rules2` | `{dev-stack}`, from `{dev-branch}`\n| Production ({prod-account}) | `eventloggerapp-prod-rules2`, API `ny63bqmiol` stage `prod_rules2` | `{prod-stack}`, from `{prod-branch}`\n|===\n\nBoth deployments consume the same sources:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Source | Zappa trigger | New stack resource\n\n| Kinesis `em_events[_staging]` | event source mapping on the Zappa function | `WorkerKinesisMapping`\n| SNS `em_events_create_event[_staging]` | subscription to the Zappa function | `WorkerCrmSubscription`\n| Watchdog schedule | EventBridge rule `...-eventlogger_app.handlers.watchdog.handler` | `WorkerFunction` `WatchdogSchedule`\n| `api.payability.com` `eventlogger_v2`, `track`, `tracking` | base path mappings to `ny63bqmiol` | `RulesApi`, switched by hand\n|===\n\n[CAUTION]\n====\nThe old and new deployments must never consume the same source at the same time: every rule action\n(emails, Slack, tasks, supplier edits) would run twice.\n====\n\n[source,mermaid]\n----\nflowchart TB\n  subgraph prep [\"0. Preparation\"]\n    P1[\"Secrets in common/crm\"] --> P2[\"CodeBuild webhook filter\"] --> P3[\"Push branches\"]\n  end\n  subgraph dev [\"1. Staging\"]\n    D1[\"Stop test_rules2 triggers\"] --> D2[\"Push develop_new_rules\"] --> D3[\"Verify\"]\n  end\n  subgraph dark [\"2. Production, dark\"]\n    K1[\"Merge to new-rules-P312, EnableTriggers=false\"] --> K2[\"Smoke-test execute-api URL\"]\n  end\n  subgraph cut [\"3. Production cutover\"]\n    C1[\"Stop Zappa Kinesis mapping, note T\"] --> C2[\"Stop Zappa watchdog\"] --> C3[\"Merge PR: EnableTriggers=true, AT_TIMESTAMP=T\"]\n    C3 --> C4[\"Unsubscribe Zappa from CRM topic\"] --> C5[\"Switch base path mappings\"] --> C6[\"Verify\"]\n  end\n  prep --> dev --> dark --> cut\n----\n\nAll commands use the AWS CLI profiles `dev` ({stage-account}) and `prod` ({prod-account}). The IDs below were\nread from the accounts on 2026-11-10; check them again before running anything.\n\n== 0. Preparation\n\n[#secrets]\n=== Secrets\n\n`{shared-secret}` already has `AUTH_JWT_RSA_PUBLIC_KEY`, `INTERNAL_JWT`, `8001` and `SEGMENT_WRITE_KEY` in\nboth accounts, which is everything the app reads. The cutover needs no new secret keys.\n\n=== Deploy credentials and tokens\n\n* `DEV_AWS_*` and `PROD_AWS_*` need the permissions listed in xref:ROOT:deployment.adoc#secrets[Build and\n  deployment]. `_deploy.yml` stops if the keys belong to the wrong account.\n* `PYB_GITHUB_TOKEN` must read the three private libraries and should not be a personal token that expires.\n\n[#codebuild]\n=== Legacy CodeBuild webhook\n\nThe production CodeBuild project `EventLoggerApp` builds every push and merged pull request using the pushed\ncommit's `buildspec.yml`. The new branches have none, so each push to them would start a failing build. The\nwebhook has one filter group today (`EVENT = PUSH, PULL_REQUEST_MERGED`). This keeps it and excludes the new\nbranches:\n\n[source,bash]\n----\naws codebuild update-webhook --profile prod --region us-east-1 --project-name EventLoggerApp \\\n  --filter-groups '[\n    [{\"type\": \"EVENT\", \"pattern\": \"PUSH\"},\n     {\"type\": \"HEAD_REF\", \"pattern\": \"^refs/heads/(develop_new_rules|new-rules-P312)$\", \"excludeMatchedPattern\": true}],\n    [{\"type\": \"EVENT\", \"pattern\": \"PULL_REQUEST_MERGED\"},\n     {\"type\": \"BASE_REF\", \"pattern\": \"^refs/heads/(develop_new_rules|new-rules-P312)$\", \"excludeMatchedPattern\": true}]\n  ]'\n\naws codebuild batch-get-projects --profile prod --region us-east-1 --names EventLoggerApp \\\n  --query 'projects[0].webhook.filterGroups'\n----\n\nFeature branches cut from `{dev-branch}` still trigger it, as every other branch does today. To undo:\n\n[source,bash]\n----\naws codebuild update-webhook --profile prod --region us-east-1 --project-name EventLoggerApp \\\n  --filter-groups '[[{\"type\": \"EVENT\", \"pattern\": \"PUSH, PULL_REQUEST_MERGED\"}]]'\n----\n\n=== Branches\n\n`{dev-branch}` deploys to staging and `{prod-branch}` to production. Branch protection and the `dev` and\n`prod` GitHub environments are set up by a repository admin; `{prod-branch}` should only change through\nreviewed pull requests.\n\n== 1. Staging\n\n`[dev]` in `samconfig.toml` deploys with `EnableTriggers=true`, so the Zappa staging triggers go first.\n\n. Stop the Zappa `test_rules2` triggers:\n+\n[source,bash]\n----\nexport AWS_PROFILE=dev AWS_REGION=us-east-1\naws lambda update-event-source-mapping --uuid 41bcd957-2d81-450b-a653-7885b3c8e4f7 --no-enabled\naws sns unsubscribe --subscription-arn arn:aws:sns:us-east-1:605668041275:em_events_create_event_staging:ac1e4dbd-c507-4026-a960-f0a4d7296dae\naws events disable-rule --name 5272f589a2e03ec2932be4-eventlogger_app.handlers.watchdog.handler\naws events disable-rule --name 5272f589a2e03ec2932be-zappa-keep-warm-handler.keep_warm_callback\n----\n+\nNOTE: `em_events_create_event_staging` is also subscribed by the old-rules dev stack\n(`eventlogger-app-dev-new-EventLoggerFunction-59Up7u3Sq1Fb`), so staging CRM events are already captured\ntwice today. That subscription is outside this migration.\n\n. Push `{dev-branch}`. The Deploy workflow runs CI, then `make deploy ENV=dev`, and lists the stack outputs in\n  the job summary.\n\n. Check the triggers and the API. Staging has no `API_BASE_PREFIX`, so routes sit directly under the stage:\n+\n[source,bash]\n----\naws cloudformation describe-stacks --stack-name eventlogger-rules-dev --query 'Stacks[0].Outputs'\naws lambda list-event-source-mappings --function-name <WorkerFunctionArn> --query 'EventSourceMappings[].State'\n\nAPI=https://<RulesApiId>.execute-api.us-east-1.amazonaws.com/stage\ncurl -s -H \"X-Session-Token: $TOKEN\" -H \"X-InstanceId: 1\" \"$API/constants/statuses\"\ncurl -s -H \"X-Session-Token: $TOKEN\" -H \"X-InstanceId: 1\" \"$API/event_rules/?limit=5\"\n----\n\n. Compare with what `test_rules2` did: rule executions in `em_event_rule_logs_v2_staging`, a watchdog run\n  every 2 minutes, stream processing and CRM events\n  (`sam logs --stack-name eventlogger-rules-dev --name WorkerFunction --profile dev`).\n\nTo go back in staging, set `EnableTriggers=false` in `[dev]`, re-enable the mapping and rules from step 1, and\nsubscribe the Zappa function again:\n\n[source,bash]\n----\naws sns subscribe --topic-arn arn:aws:sns:us-east-1:605668041275:em_events_create_event_staging \\\n  --protocol lambda --notification-endpoint arn:aws:lambda:us-east-1:605668041275:function:eventloggerapp-test-rules2\n----\n\n== 2. Production, dark\n\n. Open a pull request from `{dev-branch}` to `{prod-branch}` and merge it after review. `[prod]` keeps\n  `EnableTriggers=false`: the stack has no Kinesis mapping or CRM subscription, and the watchdog schedule is\n  `DISABLED`.\n\n. Confirm nothing consumes events yet:\n+\n[source,bash]\n----\nexport AWS_PROFILE=prod AWS_REGION=us-east-1\naws cloudformation describe-stacks --stack-name eventlogger-rules-prod --query 'Stacks[0].Outputs'\naws lambda list-event-source-mappings --function-name <WorkerFunctionArn>   # empty\naws sns list-subscriptions-by-topic --topic-arn arn:aws:sns:us-east-1:625663093496:em_events_create_event \\\n  --query 'Subscriptions[].Endpoint'                                                 # no eventlogger-rules-prod function\n----\n\n. [[smoke-test]]Smoke-test `RulesApi` on its execute-api URL with read-only calls. Production routes are under\n  `{api-prefix}`:\n+\n[source,bash]\n----\nAPI=https://<RulesApiId>.execute-api.us-east-1.amazonaws.com/prod\ncurl -s -H \"X-Session-Token: $TOKEN\" -H \"X-InstanceId: 1\" \"$API/eventlogger_v2/constants/rules\"\ncurl -s -H \"X-Session-Token: $TOKEN\" -H \"X-InstanceId: 1\" \"$API/eventlogger_v2/event_rules/?limit=5\"\ncurl -s -H \"X-Session-Token: $TOKEN\" -H \"X-InstanceId: 1\" \"$API/eventlogger_v2/action_templates/\"\n----\n+\nWARNING: Don't call `/track`, `/capture-message`, `/simulate/rules` with real rules, or `/trigger/rules`:\nthey publish real events or run real actions.\n\n== 3. Production cutover\n\nPick a quiet window. The `em_events` stream keeps 168 hours of data and the CRM topic carries about 8 messages\na day, so there's no time pressure between the steps.\n\nPrepare a pull request to `{prod-branch}` in advance that changes:\n\n* `samconfig.toml`, `[prod.deploy.parameters]`: `EnableTriggers=true`\n* `template.yaml`, `EnvironmentSettings.prod`: `KinesisStartingPosition: AT_TIMESTAMP` and\n  `KinesisStartingTimestamp: \"<T>\"`, filled in at step 2\n\nThen:\n\n. *Check that Zappa is caught up.* `IteratorAge` of `eventloggerapp-prod-rules2` should be a few seconds (it\n  peaked at about 12 s over a day):\n+\n[source,bash]\n----\nexport AWS_PROFILE=prod AWS_REGION=us-east-1\naws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name IteratorAge \\\n  --dimensions Name=FunctionName,Value=eventloggerapp-prod-rules2 --statistics Maximum --period 60 \\\n  --start-time \"$(date -u -v-15M +%FT%TZ)\" --end-time \"$(date -u +%FT%TZ)\"\n----\n\n. *Stop the Zappa Kinesis consumer and note the time.*\n+\n[source,bash]\n----\nT=$(date -u +%s); echo \"T=$T\"\naws lambda update-event-source-mapping --uuid 030cc522-988e-4110-a1ef-1229b94b99b6 --no-enabled\naws lambda get-event-source-mapping --uuid 030cc522-988e-4110-a1ef-1229b94b99b6 --query State   # wait for Disabled\n----\n+\nThe new mapping starts with records that arrived at `T` or later. A batch Zappa was processing when it was\ndisabled can overlap by a few seconds; a later `T` would skip events instead.\n\n. *Stop the Zappa watchdog and keep-warm rules.*\n+\n[source,bash]\n----\naws events disable-rule --name 502c129d7cbb7e4f395e07-eventlogger_app.handlers.watchdog.handler\naws events disable-rule --name 502c129d7cbb7e4f395e0-zappa-keep-warm-handler.keep_warm_callback\n----\n\n. *Merge the prepared pull request* with `KinesisStartingTimestamp: \"<T>\"`. The Deploy workflow creates the\n  Kinesis mapping at `T` and the CRM subscription, and enables the watchdog schedule.\n\n. *Move the CRM subscription off Zappa* as soon as the deploy finishes. Unsubscribing only once the new\n  subscription exists means a CRM event can at worst be captured twice in that short window, instead of being\n  lost: SNS doesn't keep undelivered messages.\n+\n[source,bash]\n----\naws sns unsubscribe --subscription-arn arn:aws:sns:us-east-1:625663093496:em_events_create_event:d1300b55-595d-4a88-90d5-9548a4830043\n----\n\n. *Check the new consumers.* The mapping is `Enabled` and its `IteratorAge` drops to seconds as it catches up\n  from `T`, new items appear in `em_event_rule_logs_v2`, and the watchdog runs every 15 minutes:\n+\n[source,bash]\n----\naws lambda list-event-source-mappings --function-name <WorkerFunctionArn> \\\n  --query 'EventSourceMappings[].[State,StartingPosition]'\nsam logs --stack-name eventlogger-rules-prod --name WorkerFunction --profile prod --tail\n----\n\n. *Switch the API.* Point the three `api.payability.com` base path mappings at `RulesApi`, stage `prod`:\n+\n[source,bash]\n----\nAPI_ID=<RulesApiId output>\nfor bp in eventlogger_v2 track tracking; do\n  aws apigateway update-base-path-mapping --domain-name api.payability.com --base-path \"$bp\" \\\n    --patch-operations op=replace,path=/restapiId,value=\"$API_ID\" op=replace,path=/stage,value=prod\ndone\naws apigateway get-base-path-mappings --domain-name api.payability.com \\\n  --query \"items[?basePath=='eventlogger_v2' || basePath=='track' || basePath=='tracking']\"\n----\n\n. *Verify through the custom domain.* Repeat the <<smoke-test,smoke test>> GETs against\n  `https://api.payability.com/eventlogger_v2/...`, and check that `/track` and `/tracking/...` clients get\n  2xx responses (`sam logs ... --name ApiFunction`).\n\nAfter the cutover, leave the Kinesis start position alone: see\nxref:ROOT:configuration.adoc#kinesis-start-position[Kinesis start position]. If anything goes wrong, follow\nxref:rollback.adoc[].\n"},{"component":"eventlogger-rules","module":"operations","path":"operations:rollback.adoc","title":"Rollback","description":"Returning production from the SAM stack to the Zappa deployment after a cutover.","url":"https://docx.payability.com/eventlogger-rules/operations/rollback.html","navOrder":12,"source":"= Rollback\n:description: Returning production from the SAM stack to the Zappa deployment after a cutover.\n\nRollback reverses xref:cutover.adoc#3-production-cutover[the production cutover]. Run the steps in order. Point\nthe API back first; it has no ordering constraint. The two consumers are handled differently, to avoid losing\nevents the way the cutover does:\n\n* *CRM topic:* resubscribe Zappa _before_ the new subscription is removed. SNS doesn't keep undelivered\n  messages, so removing the new subscription first would drop every CRM event until Zappa's is restored. The\n  overlap mirrors the cutover: a CRM event can at worst be captured twice, never lost.\n* *Kinesis stream:* stop the new consumer before re-enabling Zappa's, so the stream is never processed by both\n  at once (that would run every rule action twice). Zappa resumes from its own checkpoint, so nothing is\n  skipped.\n\n. *Point the API back to Zappa.*\n+\n[source,bash]\n----\nexport AWS_PROFILE=prod AWS_REGION=us-east-1\nfor bp in eventlogger_v2 track tracking; do\n  aws apigateway update-base-path-mapping --domain-name api.payability.com --base-path \"$bp\" \\\n    --patch-operations op=replace,path=/restapiId,value=ny63bqmiol op=replace,path=/stage,value=prod_rules2\ndone\n----\n\n. *Resubscribe Zappa to the CRM topic first,* before the new stack's subscription is removed, so no CRM event\n  is dropped in between. Both subscriptions are briefly live, so a CRM event can at worst be captured twice.\n+\n[source,bash]\n----\naws sns subscribe --topic-arn arn:aws:sns:us-east-1:625663093496:em_events_create_event \\\n  --protocol lambda --notification-endpoint arn:aws:lambda:us-east-1:625663093496:function:eventloggerapp-prod-rules2\n----\n\n. *Stop the new consumers.* Set `EnableTriggers=false` in `[prod]` with a revert pull request or, in an\n  emergency, run `make deploy ENV=prod AWS_PROFILE=prod` from a laptop with that value in `samconfig.toml`.\n  This deletes the Kinesis mapping and the new stack's CRM subscription (Zappa's, added above, stays) and\n  disables the watchdog schedule. Note the time `T2` at which the new mapping is gone.\n\n. *Start Zappa's Kinesis consumer and schedules again.*\n+\n[source,bash]\n----\naws lambda update-event-source-mapping --uuid 030cc522-988e-4110-a1ef-1229b94b99b6 --enabled\naws events enable-rule --name 502c129d7cbb7e4f395e07-eventlogger_app.handlers.watchdog.handler\naws events enable-rule --name 502c129d7cbb7e4f395e0-zappa-keep-warm-handler.keep_warm_callback\n----\n\n[WARNING]\n====\nThe Zappa mapping resumes from its own checkpoint, at `T`, so it processes again everything the new stack\nhandled between `T` and `T2`, including rule actions. This repeats actions but skips nothing; it is the safe\ndefault.\n\nOnly restart Zappa at `AT_TIMESTAMP` `T2` (delete the old mapping and create a new one) if the new worker was\nfully caught up when you stopped it: `IteratorAge` at or near zero on every shard at `T2`. `T2` is the\nwall-clock time the mapping was deleted, not a processed checkpoint, so if the worker was lagging, records that\narrived before `T2` may still be unprocessed and starting there would skip them. When unsure, resume from the\ncheckpoint and accept the duplicates.\n\n[source,bash]\n----\naws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name IteratorAge \\\n  --dimensions Name=FunctionName,Value=<WorkerFunction name> --statistics Maximum --period 60 \\\n  --start-time \"$(date -u -v-15M +%FT%TZ)\" --end-time \"$(date -u +%FT%TZ)\"\n----\n====\n\nTo try the cutover again later, start from step 1 of xref:cutover.adoc#3-production-cutover[the cutover] with\na new `T`. Because the mapping was deleted, the new start position applies.\n"},{"component":"eventlogger-rules","module":"operations","path":"operations:decommission.adoc","title":"Decommissioning Zappa","description":"Removing the Zappa deployments and their leftovers after the soak period.","url":"https://docx.payability.com/eventlogger-rules/operations/decommission.html","navOrder":13,"source":"= Decommissioning Zappa\n:description: Removing the Zappa deployments and their leftovers after the soak period.\n\nOnce the SAM stacks have run without a rollback for an agreed soak period:\n\n. *Remove the Zappa deployments.* Run `zappa undeploy prod_rules2` and `zappa undeploy test_rules2` from a\n  checkout of `e888027` (the Zappa settings are no longer on the new branches), or delete the functions,\n  the `ny63bqmiol` API and the EventBridge rules named in xref:cutover.adoc[] by hand.\n\n. *Delete leftovers that point at functions that no longer exist* (production account):\n+\n[cols=\"1,2\",options=\"header\"]\n|===\n| Resource | Points at\n\n| `em_events_create_event` subscription `4c7f5d7a-82f7-45f7-873b-cb29a307f16e` | `eventloggerapp-test-rules2` (deleted)\n| `em_events_create_event` subscription `7fc94fcc-2668-4569-a6b2-bc8dc8d1c578` | `eventloggerapp-prod-rules3` (deleted)\n| Event source mapping `7a0533be-11c9-4cd8-acf3-29756079cb4c` (disabled, `em_events_staging`) | `eventloggerapp-test-rules2` (deleted)\n| API `eventloggerapp-prod-rules3` | nothing\n|===\n+\n[source,bash]\n----\nexport AWS_PROFILE=prod AWS_REGION=us-east-1\naws sns unsubscribe --subscription-arn arn:aws:sns:us-east-1:625663093496:em_events_create_event:4c7f5d7a-82f7-45f7-873b-cb29a307f16e\naws sns unsubscribe --subscription-arn arn:aws:sns:us-east-1:625663093496:em_events_create_event:7fc94fcc-2668-4569-a6b2-bc8dc8d1c578\naws lambda delete-event-source-mapping --uuid 7a0533be-11c9-4cd8-acf3-29756079cb4c\naws apigateway get-rest-apis --query \"items[?name=='eventloggerapp-prod-rules3'].id\"   # then delete-rest-api\n----\n\n. *Delete the Zappa buckets* `app-eventlogger--prod-rules2` (production) and\n  `app-eventlogger--test-rules-dev` (staging), after emptying them.\n\n. *Remove the legacy CodeBuild project* `EventLoggerApp` once no branch needs it, or keep the webhook filter\n  from xref:cutover.adoc#codebuild[the cutover preparation].\n"},{"component":"internal-admin","module":"ROOT","path":"index.adoc","title":"Internal Admin","description":"The Payability internal administration client — architecture, business logic and operations.","url":"https://docx.payability.com/internal-admin/index.html","navOrder":0,"source":"= Internal Admin\n:description: The Payability internal administration client — architecture, business logic and operations.\n\n`{repo-name}` is Payability's *internal administration client*: the Angular\nsingle-page application that internal staff use to run the business. Everything\nthat is not self-service for a seller happens here — onboarding a supplier,\nunderwriting them, pricing and approving an advance, configuring marketplace\nfunding, releasing payment batches, approving balances, running reports, and\nmanaging the back-office configuration that drives all of it.\n\nIt is an *internal tool*, not a customer-facing product. There is no public\nsign-up, no marketing surface and no multi-tenant isolation inside the app: the\naudience is Payability employees, and authorisation is enforced by the backend\nagainst a per-user permission set.\n\n[IMPORTANT]\n====\nThis documentation describes the *front end* only. The business rules that\nmatter for money movement — how a fee is actually charged, how a balance is\ncomputed, whether a payment clears — live in the backend services this app\ntalks to. Where the front end owns a rule (offer pricing arithmetic, event-rule\ncomposition, form validation, guard logic), that is stated explicitly.\n====\n\n== Start here\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| If you want to… | Read\n\n| Understand what the product does in business terms\n| xref:what-it-does.adoc[What the application does]\n\n| Get productive in the codebase quickly\n| xref:orientation.adoc[Orientation for new developers]\n\n| Understand how the app is put together\n| xref:architecture:overview.adoc[Architectural overview]\n\n| Find the business logic for a particular area\n| xref:domains:index.adoc[Business domains]\n\n| Understand auth, permissions, flags, events\n| xref:platform:authentication.adoc[Platform concerns]\n\n| Run, build, test or deploy it\n| xref:ops:local-development.adoc[Development and operations]\n\n| Look up a route, action, endpoint or flag\n| xref:reference:routes.adoc[Reference]\n|===\n\nA page-by-page index is in xref:documentation-map.adoc[the documentation map].\nDomain vocabulary is defined in the xref:glossary.adoc[glossary].\n\n== The shape of the system in one picture\n\n[source]\n----\n                       ┌──────────────────────────────────────┐\n   Internal staff ───► │  Internal Admin (this application)   │\n   (Google SSO or      │  Angular {angular-version} SPA, hash routing │\n    username/password) │  NgRx store, AG Grid, Material       │\n                       └───┬──────────────┬───────────────┬───┘\n                           │              │               │\n              X-Session-Token header      │        postMessage\n                           │              │               │\n              ┌────────────▼───┐  ┌───────▼────────┐  ┌───▼──────────────┐\n              │ Internal API   │  │ Satellite      │  │ EVA micro-front  │\n              │ (database_v2)  │  │ services       │  │ end (iframes)    │\n              │ suppliers,     │  │ actionstools,  │  │ supplier top bar,│\n              │ payments,      │  │ eventlogger,   │  │ menu, contracts, │\n              │ reports, fees, │  │ messaging,     │  │ onboarding views │\n              │ perms, configs │  │ documents,     │  └──────────────────┘\n              └────────────────┘  │ authtools,     │\n                                  │ marketplace,   │\n                                  │ taskmanager,   │\n                                  │ uw-tool, s3    │\n                                  └────────────────┘\n----\n\nThe application is a *thin, stateful client over many backends*. There is no\nsingle API gateway: the environment configuration carries roughly a dozen base\nURLs, each service wrapper reads the one it needs from `AppConfigService`, and a\nsingle interceptor attaches the session token to every request. See\nxref:architecture:integration-landscape.adoc[the integration landscape] for the\nfull map and xref:reference:services-and-endpoints.adoc[the endpoint reference]\nfor which service talks to which host.\n\n== What makes this codebase distinctive\n\nAngular 12 with NgRx 4::\nThe state layer predates `createAction`/`createReducer` and `@ngrx/entity`. Actions\nare hand-written classes with `readonly type` string constants, effects use the\n`@Effect()` decorator and `this.actions$.ofType(...)`. This is deliberate — see\nxref:architecture:state-management.adoc[state management] before adding store code.\n\nFractal module structure::\nFeature modules recurse: a module may contain `model/`, `services/`, `store/` and\nsub-component folders, and a sub-component folder may contain the same. Scope\ndetermines placement — application-wide code in `{source-root}/shared`,\nmodule-wide code in that module's `shared/`. See\nxref:architecture:module-map.adoc[the module map].\n\nSelective state, not total state::\nOnly a handful of slices are in the store (auth, supplier, crm, layout, utils,\nglobal settings, marketplace, onboarding, underwriting, background reports,\ncalculator, fetched requests). Most feature state is component-local. Do not add\na store slice for something that does not need one.\n\nPermission-driven navigation::\nNearly every route carries an `ngx-permissions` clause, and the sidebar mirrors it\nwith `*ngxPermissionsExcept` / `*ngxPermissionsOnly`. Two permissions —\n`REST_UW_ACCESS` and `ADVANCE_AUDITORS` — act as broad *deny* filters across the\nwhole app. See xref:platform:authorization.adoc[authorisation].\n\nTwo generations of almost everything::\nEvent rules v1 and v2, old and new search engines, `supplier-underwriting` and\n`supplier-underwriting-beta`, `data-table` / `data-table-grid` /\n`data-table-ag-grid` / `pyb-ag-grid`, `b2b-document` and `pyb-documents`. Migrations\nwere started and not finished. Each doc page names which generation is current.\n\nMicro-front-end seams::\nParts of the supplier workspace are rendered by *EVA*, a separate front end\nembedded in iframes and driven by a typed `postMessage` protocol. See\nxref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\n== Scope and freshness\n\nThis component documents the state of the `{default-branch}` branch. Facts that\nchange with the code — route tables, action names, endpoint lists, flag and\npermission catalogues — are collected in the xref:reference:routes.adoc[reference\nmodule] so that they can be re-derived in one place when the code moves.\n\nWhere a page states a rule, it cites the file that implements it. If the citation\nno longer matches the code, trust the code and fix the page.\n"},{"component":"internal-admin","module":"ROOT","path":"what-it-does.adoc","title":"What the application does","description":"The business capabilities the Internal Admin exposes, in the order staff encounter them.","url":"https://docx.payability.com/internal-admin/what-it-does.html","navOrder":1,"source":"= What the application does\n:description: The business capabilities the Internal Admin exposes, in the order staff encounter them.\n\nPayability advances cash against marketplace receivables. A *supplier* (a seller\non Amazon, Walmart, Target and similar *marketplaces*) is onboarded, underwritten,\npriced, funded and then paid on a recurring schedule while Payability recovers its\nadvances from the marketplace remittances. Internal Admin is the console for every\none of those steps.\n\n== The supplier lifecycle\n\n[source]\n----\n   acquire            onboard              underwrite            fund\n ┌──────────┐      ┌────────────┐       ┌────────────┐      ┌──────────────┐\n │ referral │─────►│ KYC docs,  │──────►│ initial /  │─────►│ advance offer│\n │ or CRM   │      │ marketplace│       │ daily UW,  │      │ priced and   │\n │ deal     │      │ linking,   │       │ background │      │ approved     │\n └──────────┘      │ contracts  │       │ reports,   │      └──────┬───────┘\n                   └────────────┘       │ UCC filing │             │\n                                        └────────────┘             ▼\n                                                            ┌──────────────┐\n   wind down            operate                             │ payments run │\n ┌──────────────┐   ┌──────────────────────┐                │ on schedule  │\n │ suspend,     │◄──│ fees, reserves,      │◄───────────────│ in batches   │\n │ cancel, lock,│   │ statements, cards,   │                └──────────────┘\n │ write off,   │   │ receivables, health, │\n │ delete       │   │ balance approval     │\n └──────────────┘   └──────────────────────┘\n----\n\nEach stage maps onto a tab of the supplier workspace and onto a documentation\npage:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Stage | What staff do | Documentation\n\n| Acquire\n| Track the deal in CRM, log notes/tasks/emails against the supplier, generate a\n  referral link.\n| xref:domains:crm.adoc[CRM], xref:domains:suppliers.adoc[Suppliers]\n\n| Onboard\n| Walk an onboarding template, collect and verify KYC documents, link marketplace\n  accounts, capture owners and business details, sign contracts, set up 2FA and\n  SMS numbers.\n| xref:domains:supplier-onboarding.adoc[Onboarding and KYC]\n\n| Underwrite\n| Run initial and daily underwriting, pull background/bank/ML reports, record UCC\n  filings, and approve or decline.\n| xref:domains:supplier-underwriting.adoc[Underwriting],\n  xref:domains:underwriting-tracker.adoc[UW tracker]\n\n| Price and fund\n| Build a progressive-pricing advance offer, set risk rating, periods and fees,\n  compute net delivery and repayment reserve, approve or re-offer.\n| xref:domains:advances.adoc[Advances and offers]\n\n| Operate\n| Configure payment methods and deposit accounts, adjust purchase/payment fees\n  and reserves, review marketplace statements, issue and manage cards, add\n  receivables, chargebacks and write-offs, watch account health.\n| xref:domains:supplier-payments.adoc[Payments],\n  xref:domains:fees-and-reserves.adoc[Fees and reserves],\n  xref:domains:cards.adoc[Cards]\n\n| Pay out\n| Create, review and release fund-transfer batches; approve balances before money\n  moves.\n| xref:domains:processes.adoc[Processes and batches],\n  xref:domains:balance-approval.adoc[Balance approval]\n\n| Wind down\n| Suspend, cancel, lock, hide, write off or delete an account, or run a win-back.\n| xref:domains:supplier-lifecycle.adoc[Lifecycle actions]\n|===\n\n== Capability areas\n\nThe left-hand navigation groups the application into these areas. The mapping\nbetween menu item, route and implementation is in\nxref:reference:routes.adoc[the route catalogue].\n\nYour tasks / Group tasks::\nTask counters and the CRM task list — the staff work queue.\nSee xref:domains:crm.adoc[CRM tools and tasks].\n\nSearch::\nGlobal supplier search backed by the internal API's Elasticsearch-style\n`search3` endpoint, with an older `search2` path still present.\nSee xref:platform:shared-library.adoc#search[Search overlay].\n\nActions::\nCross-cutting money and data operations available from anywhere: load earnings,\nload remittance, add chargeback, add bad-debt recovery, add receivable, add\nwrite-off, add default contract, edit marketplace statement, add or zero a\nmarketplace payment, adjust the Payability fee, add a fund-transfer request.\nSee xref:domains:supplier-lifecycle.adoc[Lifecycle actions and transactions].\n\nMarketplaces::\nThe catalogue of marketplaces and payment gateways Payability integrates with.\nSee xref:domains:marketplaces.adoc[Marketplaces].\n\nUW Tracker::\nA cross-supplier underwriting queue, gated by the `UNDERWRITING_TRACKER` flag.\nSee xref:domains:underwriting-tracker.adoc[Underwriting tracker].\n\nReports::\nInternal reports (SQL views exposed with column configuration), external reports\n(Plaid, bank categorisation, ML predictions) and the waiting-suppliers report.\nSee xref:domains:reports.adoc[Reports].\n\nPayments::\nFund-transfer batches and balance approval.\nSee xref:domains:processes.adoc[Processes] and\nxref:domains:balance-approval.adoc[Balance approval].\n\nEmails::\nThe outbound email log and preview, shared with the CRM email components.\nSee xref:domains:emails.adoc[Emails and messaging].\n\nManage::\nBack-office configuration: bulk actions, event rules (v1 and v2), users,\npermission groups, the raw event stream, onboarding templates, email settings,\nglobal settings, fee settings, contracts and month-end close.\nSee xref:domains:manage.adoc[Manage].\n\nDEV Admin::\nDeveloper-only tooling — runtime feature-flag switching and QA helpers, gated on\nthe `DEV` permission.\nSee xref:domains:admin.adoc[DEV Admin].\n\n== Who uses it\n\nAccess is shaped by permission code rather than by job title, but in practice:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Group | Typical permissions and reach\n\n| Underwriting\n| `UNDERWRITING`; full supplier workspace including underwriting tabs, advances\n  and offer pricing.\n\n| Restricted underwriting\n| `REST_UW_ACCESS`; a deliberately narrow slice — most modules and most supplier\n  tabs are hidden. Access by this group is additionally *logged* as a KYC\n  guardrail event.\n\n| Advance auditors\n| `ADVANCE_AUDITORS`; read-oriented access to advance data with the rest of the\n  app filtered out; supplier detail loading skips funding, balance and contract\n  calls entirely.\n\n| Finance / treasury\n| `FINANCE`, `INT_PMT_ADMIN`, `INT_PMT_REVIEW`, `MANAGE_PMT`,\n  `MANAGE_BAD_DEBT`; payments, batches, balance approval, background reports.\n\n| Customer service / sales / marketing\n| `CUST_SERVICE`, `Sales`, `MARKETING`; CRM, supplier general data, emails.\n\n| Administrators\n| `INT_SUPER_USER`, `INT_PERMS_ADM`, `INT_GLOBAL_SETTINGS`,\n  `INT_EMAIL_SETTINGS`, `INT_RULES_UPDATE`, `INT_REP_ADMIN`; the Manage area.\n\n| Developers\n| `DEV`, `dev_support`; DEV Admin plus everything else.\n|===\n\nThe authoritative list is xref:reference:permission-catalog.adoc[the permission\ncatalogue]; the enforcement mechanics are in\nxref:platform:authorization.adoc[authorisation].\n"},{"component":"internal-admin","module":"ROOT","path":"orientation.adoc","title":"Orientation for new developers","description":"How to find your way around the codebase and make a first safe change.","url":"https://docx.payability.com/internal-admin/orientation.html","navOrder":2,"source":"= Orientation for new developers\n:description: How to find your way around the codebase and make a first safe change.\n\n== First hour\n\n. Read xref:what-it-does.adoc[what the application does] so that domain words\n  (supplier, marketplace, advance, opt-in, reserve, batch) mean something.\n. Get it running: xref:ops:local-development.adoc[local development]. The single\n  most common first-run failure is forgetting the environment generation step.\n. Log in against a dev environment and click through: supplier list → open a\n  supplier → walk the tabs. The tab strip *is* the domain model.\n. Open Redux DevTools. Every meaningful state transition is a named action; the\n  action log is the fastest way to learn how a screen is wired.\n\n== How to locate code\n\nThe repository is organised by *feature*, not by file type. Start from the URL.\n\n[source]\n----\nURL                                   Route table                         Feature module\n/#/suppliers/<key>/advances     →  suppliers/suppliers-routing.routes.ts  →  suppliers/supplier/supplier-advances/\n/#/manage/event-rules           →  manage/manage-routing.routes.ts        →  manage/event-rules/\n/#/processes/list               →  processes/processes-routing.routes.ts  →  processes/process-list/\n/#/reports/<name>               →  reports/reports-routing.routes.ts      →  reports/report-table/\n----\n\nAll top-level route tables are aggregated in\n`{source-root}/app-routes.module.ts`; the catalogue is\nxref:reference:routes.adoc[here].\n\nWithin a feature module the conventional layout is:\n\n[source]\n----\n<feature>/\n├── model/                  interfaces and DTO classes, one concept per file\n├── services/               @Injectable HTTP wrappers\n├── store/                  NgRx actions / reducers / effects (only if stateful)\n├── shared/                 code shared *within* this module\n├── <sub-feature>/          component .ts / .html / .scss / .spec.ts\n└── <feature>.module.ts     declarations and providers\n----\n\nUseful entry points:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Why it matters\n\n| `{source-root}/app.module.ts`\n| The composition root: every eagerly loaded module, the reducer map, the effects\n  list, and every application-level provider and DI substitution.\n\n| `{source-root}/app-routes.module.ts`\n| Aggregates all feature route tables under `MainLayoutComponent`.\n\n| `{source-root}/store/app.reducers.ts`\n| The complete `AppState` shape — 12 slices, nothing hidden.\n\n| `{source-root}/shared/sidenav/sidenav-menu/sidenav-menu.component.html`\n| The navigation tree with its permission and feature-flag conditions; the\n  clearest single statement of \"what exists and who can see it\".\n\n| `src/environments/environment.interface.ts`\n| Every externally configurable value, and therefore every backend the app talks to.\n\n| `{source-root}/shared/users/user-permissions.enum.ts`\n| The permission vocabulary.\n\n| `{source-root}/shared/feature-flags/feature-flags.const.ts`\n| The feature-flag vocabulary.\n|===\n\n== Things that will surprise you\n\nHash routing::\n`useHash: true` in `app-routes.module.ts`. URLs look like `/#/suppliers/list`.\nRedirect and deep-link handling — including the post-login redirect stored in\n`sessionStorage` — strips the `#`. See xref:architecture:routing.adoc[routing].\n\nCompiled `.js` and `.js.map` files are checked in::\nMany folders contain stale `*.js` / `*.js.map` siblings next to the `.ts` source\n(for example `auth.effects.js`). They are build residue from an earlier toolchain,\nnot inputs. Always edit the `.ts`. Ignore the `.js` in searches — grepping without\nexcluding them produces duplicate hits.\n\n`util` imports in application code::\n`isUndefined`, `isNull`, `isString`, `isArray`, `isNullOrUndefined` are imported\nfrom Node's `util` module throughout the reducers and effects. It works because\nof the webpack Node polyfill; it is not a pattern to extend.\n\n`rxjs-compat` and deep RxJS imports::\nBoth `rxjs/operators` pipe style and legacy deep imports\n(`rxjs/observable/forkJoin`, `rxjs/observable/ErrorObservable`) appear. New code\nuses the pipe style.\n\nMock services everywhere::\nAlmost every service has a `*-mock.service.ts` sibling, and several have an\n`*-abstract.service.ts` base with the concrete class injected by token in\n`app.module.ts`. This is the seam used by unit tests. See\nxref:architecture:http-and-services.adoc#abstractions[service abstractions].\n\nTwo `processes` route prefixes::\n`/processes` and the misspelled `/proceses` both exist and both resolve; the\nsidebar links to `/proceses/approval` for Balance Approval. Do not \"fix\" it\nwithout checking bookmarks and the batches deep links.\n\nSerialisation decorators::\nModels use `cerialize` (`@autoserialize`, `Deserialize`, `SerializeKeysTo`) to\ntranslate between the API's `snake_case` and the app's `camelCase`. The key\ncasing is set *globally* at call time, which makes ordering significant. See\nxref:architecture:data-modeling.adoc[data modelling].\n\n== Making your first change\n\nThe repository runs a *spec-first* workflow with four agent phases (planner →\nplan reviewer → implementer → implementation reviewer). Specs live in `.specs/`.\nFor anything beyond a trivial fix, write or update the spec first — see\nxref:ops:spec-driven-workflow.adoc[the spec-driven workflow].\n\nPractical checklist for a change:\n\n. Find the nearest existing pattern and copy it. Consistency beats improvement here.\n. Keep the diff small enough to review in one sitting.\n. `npm run lint` and `npm run test-single` must pass. Husky enforces lint on\n  commit and tests on push — do not bypass with `--no-verify`.\n. `npm run format` before finishing; Prettier is authoritative\n  (single quotes, semicolons, 2-space indent, `printWidth` 140, trailing commas).\n. Add a unit test for new behaviour; add a Cypress test only for user-facing\n  flows, and note that the Cypress suite is currently *not maintained*.\n. Guard new modules and routes with a feature flag by default — see\n  xref:platform:feature-flags.adoc[feature flags].\n\n== What not to do\n\n* Do not upgrade Angular, NgRx, RxJS, AG Grid or the test runner. The versions are\n  pinned deliberately; see xref:architecture:decisions.adoc[decisions and constraints].\n* Do not introduce a second state library, or `@ngrx/entity`/`createAction` style\n  into the NgRx 4 store.\n* Do not hardcode a base URL. Read it from `AppConfigService`.\n* Do not re-implement authentication in a service; `AuthInterceptor` handles it.\n* Do not add a store slice for state that one component owns.\n"},{"component":"internal-admin","module":"ROOT","path":"documentation-map.adoc","title":"Documentation map","description":"Every page in this component, what it covers, and when to reach for it.","url":"https://docx.payability.com/internal-admin/documentation-map.html","navOrder":3,"source":"= Documentation map\n:description: Every page in this component, what it covers, and when to reach for it.\n\n== ROOT — orientation\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:index.adoc[Internal Admin]\n| What the application is, the system diagram, what makes the codebase distinctive.\n\n| xref:what-it-does.adoc[What the application does]\n| Business capabilities, the supplier lifecycle, capability areas, who uses it.\n\n| xref:orientation.adoc[Orientation for new developers]\n| How to locate code, surprises, first-change checklist, what not to do.\n\n| xref:documentation-map.adoc[Documentation map]\n| This page.\n\n| xref:glossary.adoc[Glossary]\n| Business and codebase vocabulary.\n|===\n\n== Architecture — how it is built\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:architecture:overview.adoc[Architectural overview]\n| Layers, request/response flow, the dependency rules that hold.\n\n| xref:architecture:tech-stack.adoc[Technology stack]\n| Every pinned dependency, why it is there, and what it constrains.\n\n| xref:architecture:module-map.adoc[Module map]\n| The fractal module structure, every top-level module, and placement rules.\n\n| xref:architecture:bootstrap.adoc[Bootstrap and composition root]\n| `main.ts` → `AppModule`: providers, DI substitutions, reducer and effect wiring.\n\n| xref:architecture:routing.adoc[Routing and navigation]\n| Hash routing, route aggregation, guard order, resolvers, deep-link handling.\n\n| xref:architecture:state-management.adoc[State management]\n| NgRx 4 patterns, all 12 slices, effect idioms, router-driven state, persistence.\n\n| xref:architecture:http-and-services.adoc[HTTP layer and services]\n| Service conventions, interceptor chain, abstractions and mocks, error mapping.\n\n| xref:architecture:data-modeling.adoc[Data modelling and serialisation]\n| `cerialize` usage, casing translation, model/DTO/interface conventions.\n\n| xref:architecture:ui-composition.adoc[UI composition and layout]\n| Shell layout, sidebar, CRM panel, dialogs, the four table generations, forms.\n\n| xref:architecture:integration-landscape.adoc[Integration landscape]\n| Every backend and third party, what it is used for, and how it is reached.\n\n| xref:architecture:decisions.adoc[Architectural decisions and constraints]\n| The standing decisions, why they hold, and the known debt.\n|===\n\n== Domains — business logic\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:domains:index.adoc[Business domains]\n| Domain index, ownership of rules between front end and backend.\n\n| xref:domains:suppliers.adoc[Suppliers]\n| The central entity, the workspace, the supplier store slice, partial editing.\n\n| xref:domains:supplier-onboarding.adoc[Onboarding and KYC]\n| Templates, product matrix, document verification, 2FA, SMS, owners.\n\n| xref:domains:supplier-underwriting.adoc[Underwriting]\n| Initial/daily UW, background reports, documentation, UCC, real-time validation.\n\n| xref:domains:advances.adoc[Advances and offers]\n| Offer lifecycle and the progressive-pricing calculator arithmetic.\n\n| xref:domains:supplier-payments.adoc[Supplier payments and deposit accounts]\n| Payment configs, deposit accounts, Plaid, auto-payment, payment history.\n\n| xref:domains:fees-and-reserves.adoc[Fees, reserves and contracts]\n| Purchase/payment fees, marketplace reserves, minimum-fee overrides, contracts.\n\n| xref:domains:cards.adoc[Card programmes]\n| EFS, Marqeta and Airwallex — providers, statuses, issuing flows, and Airwallex\n  transaction disputes.\n\n| xref:domains:supplier-lifecycle.adoc[Lifecycle actions and transactions]\n| Hide/suspend/lock/cancel/delete, win-back, and the Actions menu operations.\n\n| xref:domains:marketplaces.adoc[Marketplaces and gateways]\n| Marketplace catalogue, gateways, supplier-marketplace links and statements.\n\n| xref:domains:processes.adoc[Payment processes and batches]\n| Batch lifecycle, process add/edit, payment details, downloads.\n\n| xref:domains:balance-approval.adoc[Balance approval]\n| The treasury review queue, decisions, statistics, outcome reasons.\n\n| xref:domains:crm.adoc[CRM tools and tasks]\n| Timeline streams, notes/tasks/files/emails/events, task filters and counters.\n\n| xref:domains:emails.adoc[Emails and messaging]\n| The email log, preview, templates, batch email and messaging service.\n\n| xref:domains:reports.adoc[Reports]\n| Internal report configuration, external reports, waiting suppliers, CSV export.\n\n| xref:domains:manage.adoc[Manage]\n| Bulk actions, users, permission groups, settings, onboarding templates, close.\n\n| xref:domains:underwriting-tracker.adoc[Underwriting tracker]\n| The cross-supplier UW queue.\n\n| xref:domains:admin.adoc[DEV Admin]\n| Runtime flag switching and QA tooling.\n|===\n\n== Platform — cross-cutting concerns\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:platform:authentication.adoc[Authentication and session]\n| Google and basic login, token handling, expiry, logout, redirect restore.\n\n| xref:platform:authorization.adoc[Authorisation and permissions]\n| `ngx-permissions`, guards, the two deny-filter permissions, sub-permissions.\n\n| xref:platform:feature-flags.adoc[Feature flags]\n| Build / global-settings / session layers, precedence, guard and directive.\n\n| xref:platform:events.adoc[Events]\n| Domain events, event rules v1 and v2, filters, actions, history, EventBridge.\n\n| xref:platform:realtime.adoc[Real-time streams (SSE)]\n| `SseService`, the task-monitoring stream, and the in-app event bus.\n\n| xref:platform:iframe-messaging.adoc[Iframe messaging and EVA]\n| The `postMessage` protocol, message catalogue, resizing, token hand-off.\n\n| xref:platform:error-handling.adoc[Error handling]\n| Global handler, parser, toast visualisation, request capture, the Tetris page.\n\n| xref:platform:caching-and-persistence.adoc[Caching and browser persistence]\n| HTTP cache, `lscache`, and every `localStorage`/`sessionStorage`/cookie key.\n\n| xref:platform:observability.adoc[Observability and analytics]\n| Segment, Google Analytics, the event logger, the KYC guardrail trace, versions.\n\n| xref:platform:shared-library.adoc[Shared library]\n| The `shared/` inventory: tables, dialogs, forms, filters, search, documents.\n|===\n\n== Ops — running and changing it\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:ops:local-development.adoc[Local development]\n| Prerequisites, environment generation, `npm start`, running against EVA, ports.\n\n| xref:ops:environments.adoc[Environments and configuration]\n| How `environment.ts` is generated, dev vs prod templates, per-env values.\n\n| xref:ops:build-and-deploy.adoc[Build and deployment]\n| Build configurations, CodeBuild specs, S3 deployment, versioned releases.\n\n| xref:ops:testing.adoc[Testing]\n| Karma/Jasmine setup, `ng-mocks`/`ng-bullet` idioms, coverage, Cypress status.\n\n| xref:ops:conventions.adoc[Code conventions]\n| Naming, structure, SCSS (ABEM), lint and format rules, branch and PR rules.\n\n| xref:ops:spec-driven-workflow.adoc[Spec-driven workflow]\n| The four-phase agent workflow, `.specs/` formats, checkpoints.\n\n| xref:ops:troubleshooting.adoc[Troubleshooting]\n| The recurring failure modes and their fixes.\n|===\n\n== Reference — lookups\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Covers\n\n| xref:reference:routes.adoc[Route catalogue]\n| Every route, its component, guards and permission clause.\n\n| xref:reference:store-actions.adoc[Store action catalogue]\n| Every action type per slice, with the effect that handles it.\n\n| xref:reference:services-and-endpoints.adoc[Services and API endpoints]\n| Every service, its base URL and its endpoints.\n\n| xref:reference:environment-variables.adoc[Environment configuration keys]\n| Every `IEnvironment` key, its source variable and its dev/prod values.\n\n| xref:reference:feature-flag-catalog.adoc[Feature flag catalogue]\n| Every flag, what it gates and where it is checked.\n\n| xref:reference:permission-catalog.adoc[Permission catalogue]\n| Every permission code and where it is enforced.\n\n| xref:reference:event-catalog.adoc[Event and message catalogue]\n| Domain event types and targets, iframe message types, in-app bus events.\n\n| xref:reference:inventory.adoc[Codebase inventory]\n| Counts and distribution of components, services, models, specs per module.\n|===\n"},{"component":"internal-admin","module":"ROOT","path":"glossary.adoc","title":"Glossary","description":"Domain and codebase vocabulary used throughout the Internal Admin.","url":"https://docx.payability.com/internal-admin/glossary.html","navOrder":4,"source":"= Glossary\n:description: Domain and codebase vocabulary used throughout the Internal Admin.\n\n== Business vocabulary\n\nAdvance::\nCash paid to a supplier ahead of marketplace remittance, repaid out of subsequent\nremittances. Modelled as `supplier_advances` with a `supplierAdvanceKey`. An\n*advance line* is a revolving facility rather than a one-off advance.\n\nAdvance offer request::\nThe priced proposal for an advance, keyed by `advanceOfferRequestKey`. Carries\nrequested/recommended/approved amounts, risk rating, fee periods, reserve and\nunderwriter notes. See xref:domains:advances.adoc[Advances and offers].\n\nBalance approval::\nTreasury review of a supplier's balance before a transfer is released. Backed by\nthe `pending_review` endpoints. See xref:domains:balance-approval.adoc[Balance approval].\n\nBatch (payment batch)::\nA group of supplier payments released together as a fund transfer, keyed by\n`processKey` / batch id. See xref:domains:processes.adoc[Processes and batches].\n\nChargeback::\nA reversal charged back to the supplier's balance, added via the Actions menu.\n\nDeposit account::\nThe supplier's bank account that Payability deposits into, keyed by\n`depositAccountKey`. Manual bank accounts can be bulk-imported by CSV.\n\nEFS::\nA prepaid card programme (Electronic Funds Source). `efsAvailable`,\n`efsRebatePercent` and `efsLastCardReqTs` on the supplier record; a dedicated\ncard-transactions tab. See xref:domains:cards.adoc[Card programmes].\n\nFund facility limit::\nThe ceiling on total funding exposure for a supplier (`fundFacilityLimit`).\n\nInstant access::\nThe product where a supplier receives daily accelerated payouts\n(`instantAccess` on the supplier record).\n\nKYC document::\nAn identity/business verification artefact tracked per supplier with a status,\nsurfaced as `kycDocumentsStatuses`. See\nxref:domains:supplier-onboarding.adoc[Onboarding and KYC].\n\nMarketplace::\nA sales channel Payability funds against (Amazon Seller Central, Amazon Vendor\nCentral/Express, Walmart, Target, …), keyed by `marketplaceKey`.\n\nMarketplace supplier (mp-supplier)::\nThe link between one supplier and one marketplace, keyed by `mpSupKey` /\n`mpSupplierKey`. Funding settings, opt-in periods, minimum-fee overrides and\nstatements all hang off this link, not off the supplier.\n\nMarqeta::\nA card issuing platform used for one of the card programmes, keyed by\n`marqetaKey`. The supplier record carries `marqetaStatuses`\n(`businessState`, `userState`).\n\nAirwallex::\nA card issuing platform behind the `CARD_MANAGEMENT_AIRWALLEX` feature flag,\nwith its own cardholder registration and terms-acceptance status.\n\nMonth-end close::\nThe periodic accounting close process exposed under Manage.\n\nOpt-in period::\nA date range in which a supplier has opted a marketplace into funding\n(`SupplierMarketplaceOptInPeriodModel`). `oldestOptIn` on the supplier is the\nearliest one.\n\nPayment config::\nA configured outgoing payment method for a supplier, keyed by\n`paymentConfigKey`. Pre-funded cards can be bulk-imported by CSV.\n\nPending edit / pending approval::\nA supplier change that requires a second pair of eyes before it takes effect\n(`pendApprovNeeded`).\n\nProgressive pricing::\nThe advance fee model: a list of *periods*, each with a length and a weekly fee\npercentage, whose totals produce the average fee. Defaults are chosen from the\nunderwriting risk rating. See xref:domains:advances.adoc[Advances and offers].\n\nReceivable::\nAn amount owed to the supplier by a marketplace that Payability can fund against.\n\nRemittance::\nThe marketplace's periodic payout, from which Payability recovers advances.\nLoaded into the system through the *Load Remittance* action.\n\nReserve (repayment reserve)::\nThe portion of incoming funds withheld to repay an advance. Expressed both as a\npercentage (`offerAdvanceReservePercent`, `extraReservePercent`) and an amount\n(`reserveTotalAmount`).\n\nRisk rating::\nThe underwriting score (an integer; `-1` means \"no rating\") that selects the\ndefault progressive-pricing period table.\n\nSupplier::\nA marketplace seller — the customer of Payability and the central entity of the\napplication, keyed by `supplierKey`.\n\nSupplier site::\nWhich branded self-service front end the supplier logs into\n(`supplierSite`, e.g. `supplier`, `supplier4`, `volusion`, `neweggcapital`). The\nallowed values are in `environment.supplierSiteVersions`.\n\nUCC::\nA Uniform Commercial Code lien filing recorded during underwriting, with a\nTransUnion lookup flow. See xref:domains:supplier-underwriting.adoc[Underwriting].\n\nUnderwriting (initial / daily)::\nInitial underwriting decides whether to fund at all; daily underwriting\nre-evaluates an active supplier. Both are tabs of the supplier workspace.\n\nWin-back::\nA campaign to re-activate a cancelled supplier.\n\nWrite-off::\nRecognising an unrecoverable balance (`writeoff` on the supplier record;\n`MANAGE_BAD_DEBT` permission).\n\n== Codebase vocabulary\n\nAction (NgRx)::\nA hand-written class with `readonly type` referencing an exported string\nconstant. NgRx 4 style — no `createAction`. See\nxref:architecture:state-management.adoc[state management].\n\nAction (event rule)::\nSomething an event rule does when it fires: `create-task`, `send-email`,\n`edit-supplier`, `push-to-event-bridge` (and a disabled `notify-on-slack`). See\nxref:platform:events.adoc[events].\n\nActions Tools::\nThe `actionstools` backend service that stores CRM activity (notes, tasks, files)\nand global settings. Base URL `actionsToolsUrl`.\n\nAppConfigService::\nThe single source of runtime configuration: version, theme and the whole\n`environment` object. Provided by factory in `app.module.ts`.\n\nCRM item / stream::\nA note, task, file, event, email or batch email attached to a *target UUID*\n(usually a supplier key). Streams are the per-type uuid lists in `CrmStateModel`\n(`all`, `notes`, `tasks`, `events`, `emails`, `batchEmails`, `files`,\n`pinnedNotes`). See xref:domains:crm.adoc[CRM].\n\nEVA::\nA separate Payability front end embedded in Internal Admin via iframes and driven\nby a typed `postMessage` protocol. Base URL `evaApiUrl`. See\nxref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\nEvent logger::\nThe `eventlogger` backend that stores the domain event stream and the\nElasticsearch-backed changelog. Base URL `eventLoggerUrl`.\n\nEvent rule::\nA stored rule that reacts to a domain event, filters it and runs actions. Two\ngenerations exist (`manage/event-rules` and `manage/event-rules-2`). See\nxref:platform:events.adoc[events].\n\nFeature flag::\nA build- or runtime-toggled capability name from the `FEATURE_FLAGS` enum, applied\nvia `FeatureFlagsService`, `FeatureFlagGuard` or the `appEnabledByFeatureFlag`\ndirective. See xref:platform:feature-flags.adoc[feature flags].\n\nFormgroup key / model part::\nThe supplier workspace edits the supplier record in *parts*: each editable card\nhas a `formGroupKey`, and the store tracks per-part in-edit / saved / error maps\n(`supplierModelPartInEdit`, `…Saved`, `…Errors`). See\nxref:domains:suppliers.adoc#partial-editing[partial editing].\n\nGCI::\nA build-time correlation id (`workflowGciId`) generated by\n`deployment/generate_gci.py` and sent to the B2B task manager.\n\nLookup / utils slice::\nReference data (timezones, funding periods, US states, countries) fetched once and\ncached in `localStorage` via `lscache` under the `utils` state slice.\n\npyb-material::\nA wrapper module that re-exports the Angular Material modules the app uses, so a\ncomponent or a spec imports one module instead of thirty.\n\nSession token::\nThe JWT held in the auth state and sent as the `X-Session-Token` header by\n`AuthInterceptor`. See xref:platform:authentication.adoc[authentication].\n\nTarget UUID::\nThe CRM's notion of \"what this activity is about\" — sent as\n`X-Actions-TargetUUID` / `X-EventLogger-TargetUUID`. For supplier pages it is the\nsupplier key.\n\nTask manager (B2B workflow)::\nThe `b2btaskmanager` backend that runs long jobs (earnings/remittance loads) and\nstreams progress over server-sent events. See xref:platform:realtime.adoc[real-time streams].\n"},{"component":"internal-admin","module":"architecture","path":"architecture:overview.adoc","title":"Architectural overview","description":"The layers of the Internal Admin, how a request flows through them, and the rules that hold between them.","url":"https://docx.payability.com/internal-admin/architecture/overview.html","navOrder":5,"source":"= Architectural overview\n:description: The layers of the Internal Admin, how a request flows through them, and the rules that hold between them.\n\n== Shape\n\nInternal Admin is a *single-page Angular application* — one eagerly-loaded bundle,\nno lazy route loading in the current route tables, hash-based URLs, deployed as\nstatic assets to S3 and served behind CloudFront/nginx. It holds a modest amount\nof shared state in an NgRx store and delegates all business rules that move money\nto backend services.\n\n[source]\n----\n┌──────────────────────────────────────────────────────────────────────┐\n│ Presentation                                                         │\n│  MainLayoutComponent shell: sidebar, CRM panel, supplier header,     │\n│  router outlet, toasts, spinners, dialogs                            │\n│  ~420 components, 18 directives, 12 pipes                            │\n└───────────┬──────────────────────────────────────────┬───────────────┘\n            │ dispatch(action) / select(selector)      │ direct service call\n            ▼                                          ▼\n┌──────────────────────────────────┐   ┌──────────────────────────────────┐\n│ State (NgRx 4)                   │   │ Application services             │\n│  12 root slices                  │   │  ~140 @Injectable services       │\n│  6 effect classes                │◄──┤  thin HttpClient wrappers        │\n│  router-store integration        │   │  plus orchestration services     │\n└───────────┬──────────────────────┘   └───────────┬──────────────────────┘\n            │                                      │\n            └──────────────┬───────────────────────┘\n                           ▼\n┌──────────────────────────────────────────────────────────────────────┐\n│ HTTP boundary                                                        │\n│  AuthInterceptor          → attaches X-Session-Token, traps 401       │\n│  FetchedRequestsInterceptor → records last 50 requests for debugging  │\n│  PendingInterceptorService → drives the global loading spinner        │\n│  HttpCacheAbstract        → optional localStorage response cache      │\n└───────────┬──────────────────────────────────────────────────────────┘\n            ▼\n┌──────────────────────────────────────────────────────────────────────┐\n│ Backends (≈15 distinct base URLs — see integration-landscape)         │\n└──────────────────────────────────────────────────────────────────────┘\n\nOrthogonal to all of the above:\n  Guards & resolvers        route admission, supplier key resolution\n  Permissions (ngx-permissions) template and route gating\n  Feature flags             build + global-settings + session layered toggles\n  Iframe bridge             typed postMessage to/from EVA\n  Error pipeline            ErrorHandler → parser → toast\n----\n\n== How a screen loads\n\nThe supplier workspace is the canonical flow and exercises nearly every layer.\n\n[source]\n----\n1. User navigates to /#/suppliers/<key>/general\n\n2. Router admission, in the order declared on the route:\n     RouteAccessResolver      → is there a user with a loginId and an unexpired JWT?\n                                if not: remember the URL in sessionStorage, dispatch\n                                Logout, redirect to /login\n     FeatureFlagsGeneralGuard → wait for auth state, then initialise feature flags\n                                (build flags + global settings + session overrides)\n     NgxPermissionsGuard      → evaluate the route's permission clause\n     SupplierKeyResolver      → read :supplierKey from the parent route and dispatch\n                                SetSupplierKey\n\n3. @ngrx/router-store emits ROUTER_NAVIGATION.\n   SupplierEffects.navigateToSupplier walks the activated route tree for a\n   supplierKey param and fans out three actions:\n     SET_SUPPLIER_KEY         → point the supplier slice at this supplier\n     SET_TARGET_UUID          → point the CRM slice at the same key\n     FETCH_SUPPLIER_DETAILS   → load the aggregate\n\n4. SupplierEffects.getSupplierDetails checks the cache in state\n   (supplier.suppliers[key]); on a miss or an explicit refresh it forkJoins:\n     SupplierService.fetchSupplierDetails         (internal API)\n     FundingParticularsService.fetchFundingParticulars\n     SupplierService.getSupplierBalance\n     LongTermContractsService.getContractsForSupplier + historical\n   For an ADVANCE_AUDITORS user the last three are replaced with of(null).\n   Result → UPDATE_SUPPLIER_MODEL.\n\n5. AuthInterceptor stamps X-Session-Token on each request; a 401 dispatches\n   Logout with a user-visible message.\n\n6. Components select from the store, render, and edit through\n   UPDATE_MODEL_PART actions keyed by formGroupKey.\n\n7. In parallel, CRM effects load the timeline for the same target UUID, and the\n   layout reducer matches the URL against its route regexes to decide whether the\n   CRM panel and supplier info panel are open, fixed or hidden.\n----\n\nThe same skeleton — guards, router-store, cache-checking effect, part-wise edit —\nrecurs across the app. Read xref:state-management.adoc[state management] for the\nidioms and xref:domains:suppliers.adoc[the suppliers domain] for the business\nmeaning.\n\n== Layer responsibilities\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Layer | Owns | Must not\n\n| Components\n| Rendering, local form state, dispatching actions, calling services for\n  non-shared data.\n| Contain business arithmetic that belongs in a service; construct URLs; read\n  `environment` directly.\n\n| Store (actions/reducers/effects)\n| Shared cross-screen state, orchestration of multi-call loads, caching of\n  already-fetched aggregates.\n| Hold state that only one component reads; perform HTTP directly in a reducer.\n\n| Services\n| One backend concern each: build the request, map the response into a model,\n  translate errors.\n| Know about components; keep UI state; hardcode a base URL.\n\n| Interceptors\n| Session token, request recording, spinner, cache.\n| Contain feature logic.\n\n| Guards / resolvers\n| Route admission and route-parameter plumbing.\n| Fetch domain aggregates (that is an effect's job — `SupplierKeyResolver`\n  dispatches rather than fetches).\n\n| Models\n| Shape and serialisation of API payloads, plus derived getters\n  (`isAmazonSeller`, `isTarget`).\n| Perform I/O.\n|===\n\n== Dependency rules that actually hold\n\n* Feature modules depend on `shared/`, never the reverse. The one systematic\n  exception is `shared/` code that reaches into `suppliers/model` for the supplier\n  types (for example `EvaCommunicationService` and `LookupRepository`) — a known\n  wart, not a licence to add more.\n* Every module may depend on `store/app.reducers.ts` for the `AppState` type.\n  This makes `AppState` a de-facto shared contract; changing a slice's shape is a\n  cross-module change.\n* `pyb-material` is a leaf: it only re-exports Angular Material.\n* `mocks/` and `*-mock.service.ts` files are test-only seams and must not be\n  referenced from production paths.\n* Configuration flows one way: `environment.ts` → `AppConfigService` → services.\n  A handful of services import `environment` directly (`EventsService`,\n  `EvaService`, `LookupRepository`, `PendingReviewService`, `FeatureFlagsService`);\n  new code should take `AppConfigService`.\n\n== Where the app is *not* layered\n\nThree areas cut across the layering and are worth knowing before you change them:\n\nSupplier \"model parts\"::\nEditing a supplier is not a single form submit. Each card owns a `formGroupKey`\nand the store keeps three parallel maps (in-edit, saved, errors) keyed by it. The\nreducer merges saved values into both `SupplierDetailsModel` and\n`FundingParticularsModel`. See xref:domains:suppliers.adoc#partial-editing[partial\nediting].\n\nCRM timeline::\nThe CRM slice is a normalised store built by hand: `crmItems` is a uuid→item map\nand each stream (`notes`, `tasks`, …) is an ordered uuid list, with a per-target\ncache in `crmStatesList`. It also keeps request headers in sync by *subscribing*\nto the store inside `CrmService`. See xref:domains:crm.adoc[CRM].\n\nEVA iframes::\nWhole regions of the supplier workspace are rendered by another application. The\nseam is a `postMessage` protocol, not a component boundary, and state is\nsynchronised explicitly in both directions. See\nxref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\n== Quality gates\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Gate | Enforcement\n\n| Lint\n| ESLint with `@angular-eslint`, `@typescript-eslint`, `prettier`,\n  `simple-import-sort`, `unused-imports`. `npm run lint`; Husky `pre-commit` runs\n  `lint-staged`.\n\n| Format\n| Prettier — single quotes, semicolons, 2-space indent, `printWidth` 140,\n  trailing commas. `npm run format`.\n\n| Unit tests\n| Karma + Jasmine, `ChromeHeadlessNoSandbox` via Puppeteer, ~400 spec files.\n  `npm run test-single`; Husky `pre-push` runs tests.\n\n| CI\n| `buildspec-ci.yml` runs lint + `test:ci` with JUnit and Cobertura reports.\n  `buildspec.yml` runs lint then the environment build. Note that `test:ci` is\n  currently commented out of the deploy buildspec.\n\n| E2E\n| Cypress {cypress-version} under `cypress/`. Marked *not maintained* in the\n  repository README.\n|===\n\nSee xref:ops:testing.adoc[testing] for details.\n"},{"component":"internal-admin","module":"architecture","path":"architecture:tech-stack.adoc","title":"Technology stack","description":"Every significant dependency, why it is present, and what it constrains.","url":"https://docx.payability.com/internal-admin/architecture/tech-stack.html","navOrder":6,"source":"= Technology stack\n:description: Every significant dependency, why it is present, and what it constrains.\n\n[IMPORTANT]\n====\nThese versions are pinned on purpose. Do not bump Angular, NgRx, RxJS, AG Grid,\nTypeScript or the test runner unless a task explicitly asks for it. See\nxref:decisions.adoc[architectural decisions] for the reasoning and the upgrade\nblockers.\n====\n\n== Core\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Concern | Version | Notes\n\n| Angular\n| {angular-version}\n| View Engine-era APIs are still in use in places; the build runs with\n  `aot: true`. Modules are eagerly loaded — `loadChildren` appears only in the\n  feature-flag documentation example, not in the live route tables.\n\n| TypeScript\n| {typescript-version}\n| `strict` is *not* fully enabled; `any` is common in older code. New code should\n  be explicitly typed regardless.\n\n| RxJS\n| {rxjs-version} (+ `rxjs-compat`)\n| Both pipeable-operator style and legacy deep imports\n  (`rxjs/observable/forkJoin`, `rxjs/observable/ErrorObservable`) exist.\n  `rxjs-compat` is what keeps the legacy paths resolving; removing it is a\n  breaking change across dozens of files.\n\n| Zone.js\n| ~0.11.4\n| Standard. Note `NgZone.run(...)` is used explicitly around Google-auth and SSE\n  callbacks, which arrive outside Angular's zone.\n\n| core-js\n| 2.6.12\n| Legacy polyfill set required by the Node 12 / older-browser target.\n|===\n\n== State\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Package | Version | Notes\n\n| `@ngrx/store`\n| {ngrx-version}\n| Pre-`createReducer`. Actions are classes; reducers are `switch` statements;\n  selectors use `createSelector` and are colocated in the actions files.\n\n| `@ngrx/effects`\n| {ngrx-version}\n| `@Effect()` decorator and `this.actions$.ofType(TYPE)`. `createEffect` does not\n  exist in this version.\n\n| `@ngrx/router-store`\n| {ngrx-version}\n| `StoreRouterConnectingModule` (no config argument in v4). Effects listen for\n  `ROUTER_NAVIGATION` and read `action.payload.routerState.root`.\n\n| `@ngrx/store-devtools`\n| {ngrx-version}\n| `StoreDevtoolsModule.instrument()` is enabled unconditionally, including in\n  production builds.\n|===\n\nA custom meta-reducer, `storeDebugger` in `{source-root}/store-debugger.ts`, is\nregistered in `StoreModule.forRoot`. See\nxref:state-management.adoc[state management].\n\n== UI\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Package | Version | Notes\n\n| `@angular/material` + `@angular/cdk`\n| {material-version}\n| Re-exported through `{source-root}/pyb-material/pyb-material.module.ts` so that\n  components and specs import one module. Theme lives in\n  `src/styles/material2-theme.scss`.\n\n| Bootstrap\n| 4.0.0-beta.2\n| A beta release, used for grid and utility classes. Coexists with Material;\n  expect specificity conflicts.\n\n| `ag-grid-community` / `ag-grid-enterprise` / `ag-grid-angular`\n| {aggrid-version}\n| The workhorse for every large table. Enterprise features are in use, so the\n  enterprise licence matters. `ag-grid-enterprise` is declared in\n  `allowedCommonJsDependencies`.\n\n| `font-awesome` 4.7 + `material-design-icons`\n| —\n| Two icon sets in parallel; templates use both `<i class=\"material-icons\">` and\n  `fa-*` classes.\n\n| `@tinymce/tinymce-angular` + `tinymce`\n| ^4.2.4 / ^5.10.6\n| Rich-text editing for emails and notes. TinyMCE assets are copied into\n  `/tinymce/` by the build (`angular.json` assets glob).\n\n| jQuery + `jquery-slimscroll`\n| ^3.5.0 / 1.3.8\n| Assigned to `window.$`/`window.jQuery` in `app.module.ts` and used by the\n  layout/sidebar directives. Not to be extended.\n\n| `ngx-toastr`\n| 6.5.0\n| The single user-facing notification channel; wrapped by\n  `ErrorVisualizationService`.\n\n| `ngx-permissions`\n| 8.1.1\n| Route guard plus `*ngxPermissionsOnly` / `*ngxPermissionsExcept` directives. See\n  xref:platform:authorization.adoc[authorisation].\n\n| `ngx-infinite-scroll`, `ngx-order-pipe`, `ngx-clipboard`, `ng-click-outside`,\n  `ngx-credit-cards`, `payment`\n| —\n| Narrow single-purpose UI helpers (CRM timeline paging, table ordering, copy\n  buttons, outside-click dismissal, card number formatting/validation).\n|===\n\n== Data, auth and platform\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Package | Version | Notes\n\n| `cerialize`\n| 0.1.18\n| Model (de)serialisation and `snake_case` ↔ `camelCase` translation. Casing is\n  set *globally* by `DeserializeKeysFrom` / `SerializeKeysTo`, which makes call\n  ordering significant. See xref:data-modeling.adoc[data modelling].\n\n| `jwt-decode`\n| 2.2.0\n| Used by `RouteAccessResolver` to read the token expiry claim.\n\n| `@types/gapi`, `@types/gapi.auth2`\n| —\n| Google Identity typings for the SSO login component.\n\n| `elasticsearch`\n| ^15.1.1\n| A *browser-side* Elasticsearch client, used only by\n  `manage/event-rules/events.service.ts` to query the `em_events` index through\n  the event-logger host. Declared in `allowedCommonJsDependencies`.\n\n| `ng-event-source`\n| ^1.0.14\n| `EventSourcePolyfill` with custom headers — required because native\n  `EventSource` cannot send `X-Session-Token`. Used by `SseService`.\n\n| `lscache`\n| 1.1.0\n| TTL-aware `localStorage` wrapper; backs the `utils` reference-data slice (24 h).\n\n| `ts-md5`\n| 1.2.2\n| Cache-key hashing in `HttpCacheLocalStorage`.\n\n| `ngx-cookie-service`\n| ^10.1.1\n| Sets the EVA auth cookie on the shared parent domain.\n\n| `moment` + `angular2-moment`\n| ^2.29.4\n| Date arithmetic and relative-time display. Also loaded as a global script.\n\n| `file-saver`\n| 1.3.3\n| CSV/report downloads. Loaded as a global script *and* imported.\n\n| `stacktrace-js`\n| 2.0.0\n| Stack resolution for the error logger.\n\n| `semver`\n| ^5.7.1\n| Front-end/back-end version comparison in `VersionService`.\n\n| `webfontloader`\n| 1.6.28\n| Async font loading.\n\n| `ngx-resource`, `@ultimate/ngxerrors`, `@angular/http`\n| —\n| Legacy leftovers. `@angular/http` (the deprecated `HttpModule`) is still\n  imported in `AppModule` even though application code uses `HttpClient`.\n|===\n\n== Build and tooling\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Tool | Role\n\n| `@angular-builders/custom-webpack` 12.1.3\n| Wraps the Angular browser builder so that `custom-webpack.config.js` can extend\n  the webpack config.\n\n| `webpack` ^4 + assorted loaders\n| Legacy loader set (`raw-loader`, `json-loader`, `angular-router-loader`,\n  `ng-router-loader`, `jshint-loader`, …) — mostly inert but still installed.\n\n| `postcss` + `autoprefixer`\n| Vendor prefixing via `postcss.config.js`.\n\n| `sass-loader` 6.0.6\n| SCSS compilation; `stylePreprocessorOptions.includePaths` adds `src/assets`.\n\n| Node {node-version} (`.nvmrc`)\n| Hard requirement. `ng-high-mem` scripts raise the heap\n  (`--max_old_space_size` 8192 for dev, 12288 in `deployment/build.sh`) because\n  the AOT build of this many components exceeds the default.\n\n| ESLint 7 + Prettier 2.5 + Husky 7 + lint-staged 12\n| Quality gates; see xref:ops:conventions.adoc[conventions].\n\n| Karma 6 + Jasmine 3.6 + Puppeteer + `ng-mocks` 12.5.1 + `ng-bullet`\n| Unit testing; see xref:ops:testing.adoc[testing].\n\n| Cypress {cypress-version} (+ `cypress-iframe`, `cypress-wait-until`,\n  `@cypress/skip-test`)\n| E2E; currently unmaintained.\n\n| `ts-node` 5\n| Runs `deployment/set-env.ts` to generate `src/environments/environment.ts`.\n\n| `junit-report-merger`, `start-server-and-test`, `concurrently`, `ttab`\n| CI report merging and local orchestration (including opening the EVA dev server\n  in a second terminal tab).\n|===\n\n== Notable build configuration\n\nFrom `angular.json`:\n\n* `outputHashing: all`, `vendorChunk: false`, `aot: true`, output to `dist/`.\n* Global styles are listed explicitly:\n  `styles.scss`, `bootstrap.scss`, `font-awesome.scss`, `layout.scss`,\n  `loader.scss`, `material2-theme.scss`, `theme.scss`, `ui.scss`, plus the two\n  icon-font stylesheets.\n* Global scripts: `jquery.min.js`, `moment.min.js`, `FileSaver.js`.\n* `allowedCommonJsDependencies`: `ag-grid-enterprise`, `jwt-decode`,\n  `angular2-moment`, `elasticsearch`, `rxjs`, `payment`.\n* Three configurations — `production` (also swaps\n  `environment.ts` → `environment.prod.ts`), `staging`, `development`;\n  `development` is the default.\n\n[NOTE]\n====\nThe `production` configuration replaces `src/environments/environment.ts` with\n`environment.prod.ts`, but `deployment/set-env.ts` writes either\n`environment.test.ts` or `environment.prod.ts` depending on `ENV_NAME`, and\n`setenv.sh` then copies that file over `environment.ts`. The file-replacement and\nthe copy are two mechanisms doing the same job. See\nxref:ops:environments.adoc[environments].\n====\n"},{"component":"internal-admin","module":"architecture","path":"architecture:module-map.adoc","title":"Module map","description":"Every top-level module, what it owns, and the rules for where new code goes.","url":"https://docx.payability.com/internal-admin/architecture/module-map.html","navOrder":7,"source":"= Module map\n:description: Every top-level module, what it owns, and the rules for where new code goes.\n\n== The fractal principle\n\nThe project is organised in what its own README calls a *fractal architecture*:\nthe folder vocabulary repeats at every depth. A module may contain `model/`,\n`services/`, `store/`, `shared/`, `enums/`, `pipes/`, `utils/` and sub-component\nfolders — and a sub-component folder may contain the same set again.\n\nimage::ROOT:structure.png[Original structure sketch from the repository README,align=center]\n\nThe placement rule is *scope*:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Scope of the thing | Where it goes\n\n| Whole application\n| `{source-root}/shared/…`\n\n| One feature module\n| `<module>/shared/…`, `<module>/services/…`, `<module>/model/…`\n\n| One component subtree\n| Inside that component's folder\n\n| Cross-module state\n| `<module>/store/…`, registered in `{source-root}/store/app.reducers.ts`\n|===\n\nAngular's \"as flat as possible\" guidance is deliberately *not* followed; the\nfolder tree is meant to mirror the business and UI structure. See\nxref:ops:conventions.adoc[conventions] for the full statement.\n\n== Top-level layout\n\n[source]\n----\nsrc/\n├── main.ts, polyfills.ts, test.ts, index.html\n├── environments/          generated + committed environment files\n├── styles/                global SCSS (8 entry points)\n├── assets/                images, fonts, SCSS include path\n└── app/\n    ├── app.module.ts              composition root\n    ├── app-routes.module.ts       route aggregation\n    ├── app.component.ts/.html     bootstrap component\n    ├── config.ts                  AppConfigService + ThemeConfig\n    ├── store/app.reducers.ts      AppState + reducer map\n    ├── store-debugger.ts          meta-reducer\n    ├── guards/                    app-level guards\n    ├── mocks/                     test-only doubles and pipes\n    ├── pyb-material/              Angular Material re-export module\n    ├── shared/                    application-wide library (~67 components)\n    ├── auth/                      login, logout, session state\n    ├── app-welcome/               landing page\n    ├── admin/                     DEV-only tooling\n    ├── suppliers/                 the core domain (~200 components)\n    ├── processes/                 payment batches\n    ├── marketplaces/              marketplace + gateway catalogue\n    ├── underwriting/              cross-supplier UW tracker\n    ├── reports/                   internal + external reporting\n    ├── crm-tools/                 notes, tasks, files, emails, events\n    ├── emails/                    email routes (components live in crm-tools)\n    └── manage/                    back-office configuration (~79 components)\n----\n\n== Module reference\n\n=== `app` (root)\n\nNot a feature module but the composition root. `AppModule` declares only\n`AppComponent` and `MainLayoutComponent`, imports every feature module eagerly,\nregisters the reducer map and effects, and provides ~25 application-level\nproviders including the DI substitutions for abstract services. Detailed in\nxref:bootstrap.adoc[bootstrap and composition root].\n\n=== `auth`\n\nLogin and session. Contains `login/`, `logout/`, `google-auth-login/`,\n`guards/auth.guard.ts`, `auth-interceptor.ts` and the `store/` slice\n(`auth.actions.ts`, `auth.reducers.ts`, `auth.effects.ts`). It owns the only\nroute table declared with `RouterModule.forChild` outside the aggregate\n(`auth-routing.module.ts`: `/login`, `/logout`).\n\nSee xref:platform:authentication.adoc[authentication].\n\n=== `app-welcome`\n\nThe post-login landing page (`/welcome`, `/welcome/:messageCode`). The message\ncode renders a canned message from `MessageService` — used for\n\"install the extension\", \"cannot receive supplier token\" and test-environment\nlogin constraints.\n\n=== `admin`\n\n`DEV`-permission-only tooling: `feature-flags/` (runtime flag switching, including\nfor EVA) and `qa/`. Routed under `/admin` with a redirect to\n`/admin/feature-flags`. See xref:domains:admin.adoc[DEV Admin].\n\n=== `suppliers`\n\nBy far the largest module (~200 components) and the heart of the application.\n\n[source]\n----\nsuppliers/\n├── enums/                     routing-number validation statuses, uw-status, …\n├── model/                     ~35 models: supplier, marketplace link, funding,\n│                              payment config, balance, cards, compliance, …\n├── services/                  ~35 services: supplier, payment config, deposit\n│                              account, cards, CLI, EFS, loans, compliance,\n│                              email preferences, referral, winback, workflow, …\n├── store/\n│   ├── supplier/              the supplier slice (actions/reducers/effects)\n│   └── background-reports/     background report slice\n├── supplier-list/             the search/browse grid\n├── supplier/                  the per-supplier workspace, one folder per tab\n│                              and one per modal action\n├── utils/, mock-data/\n└── suppliers.module.ts\n----\n\nThe `supplier/` subtree is itself a map of the domain — `supplier-general`,\n`supplier-onboarding`, `supplier-underwriting`, `supplier-advances`,\n`supplier-payment`, `supplier-fees`, `supplier-marketplaces`,\n`supplier-marketplace-statements`, `supplier-reports`, `supplier-health`,\n`supplier-receivables`, `supplier-transaction-summary`,\n`supplier-transaction-details`, `supplier-contracts`, `supplier-efs-account`,\n`supplier-marqeta-account`, `supplier-awx-account` (whose `awx-disputes/` child owns the\nxref:domains:cards.adoc#awx-disputes[Airwallex disputes] page),\n`supplier-amazon-lending`, `supplier-summary`,\n`supplier-2fa`, plus ~20 action/dialog folders (`add-receivable`,\n`add-chargeback`, `add-writeoff`, `cancel-supplier`, `suspend-supplier`,\n`delete-supplier-account`, `load-earnings`, `load-remittance`, …).\n\nSee xref:domains:suppliers.adoc[the suppliers domain].\n\n=== `processes`\n\nFund-transfer batches: `process-list`, `process-add`, `process-edit`,\n`process-batches`, `supplier-payments-details`, `pending-edit`, and\n`balance-approval` (which is routed under both `/processes/approval` and\n`/proceses/approval`). Services wrap the internal API's `paymentBatches` and\n`pending_review` endpoints.\n\nSee xref:domains:processes.adoc[processes] and\nxref:domains:balance-approval.adoc[balance approval].\n\n=== `marketplaces`\n\nThe marketplace catalogue (`marketplace-list`, `marketplace`) with `model/`,\n`service/` (including `gateway.service.ts`) and a small `store/` slice. Routed\nunder `/marketplaces`.\n\nSee xref:domains:marketplaces.adoc[marketplaces].\n\n=== `underwriting`\n\nA single component — `tracker/` — behind the `UNDERWRITING_TRACKER` flag. The\nper-supplier underwriting UI lives in `suppliers/supplier/supplier-underwriting`,\nnot here.\n\nSee xref:domains:underwriting-tracker.adoc[underwriting tracker].\n\n=== `reports`\n\n`report-list`, `report-add`, `report-edit`, `report-table`,\n`external-report-list`, `waiting-suppliers`, with `model/`, `dto/`, `pipes/`,\n`service/` and a `mock/` folder of JSON fixtures. Internal reports are\nconfiguration-driven views over database objects.\n\nSee xref:domains:reports.adoc[reports].\n\n=== `crm-tools`\n\nThe activity timeline and task system: `actions-add`, `actions-add-fabs`,\n`actions-list`, `actions-list-pinned`, `notes`, `tasks`, `files`, `emails`,\n`comments`, `events`, `crm-sidebar`, plus `models/`, `services/`, `shared/` and\nthe `store/` slice. Routes `/tasks/list/:filterId/filter`.\n\nSee xref:domains:crm.adoc[CRM].\n\n=== `emails`\n\nRoutes only (`emails-routing.routes.ts`). `/emails/list` and\n`/emails/list/:filterId/filter` render `EmailListComponent` and\n`EmailPreviewComponent` from `crm-tools/emails`. There are no components in this\nfolder — a deliberate thin routing shim.\n\nSee xref:domains:emails.adoc[emails].\n\n=== `manage`\n\nBack-office configuration (~79 components):\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Folder | Purpose\n\n| `batch-imports/`\n| Bulk CSV actions (deposit accounts, pre-funded cards, bulk notes) plus the CLI\n  action runner.\n\n| `event-rules/`\n| Event-rule v1: list, add/edit with selector and action sub-forms\n  (create-task, send-email, edit-supplier, send-to-event-bridge), and history.\n\n| `event-rules-2/`\n| The v2 (beta) rule editor.\n\n| `events/`\n| The raw event stream with badge and filter components.\n\n| `users/`, `perm-groups/`\n| Internal user and permission-group administration.\n\n| `global-settings/`, `configs/`\n| Key/value configuration, with its own store slice and selectors.\n\n| `email-settings/`\n| Outbound email configuration.\n\n| `fee-settings/`\n| Default fee configuration (list/add/edit).\n\n| `onboarding/`\n| Onboarding templates and their marketplace assignments.\n\n| `contracts/`\n| Contract templates and defaults.\n\n| `month-end-close/`\n| The accounting close screen.\n\n| `permission-error-pop-up/`\n| Shared \"access denied\" dialog.\n\n| `store/`\n| The onboarding slice.\n|===\n\nSee xref:domains:manage.adoc[manage].\n\n=== `shared`\n\nThe application-wide library — ~67 components plus directives, pipes, services,\ninterceptors, guards, resolvers and models. Grouped by concern:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Area | Contents\n\n| Layout\n| `layout/` (`MainLayoutComponent`, `app-stage-info`, `fab-holder-nav`, layout\n  store slice), `sidenav/`, `footer/`, `page-not-found/`.\n\n| Tables\n| `data-table/`, `data-table-grid/`, `data-table-ag-grid/`, `pyb-ag-grid/`,\n  `data-grid/`, `simple-table/`, `cell-components/`, `pyb-pagination/`.\n  Four generations — see xref:ui-composition.adoc#tables[table generations].\n\n| Dialogs\n| `dialog/` (module + service), `confirm-dialog/`, `confirm-delete/`,\n  `information-dialog/`, `reason-dialog/`, `summary-dialog/`.\n\n| Forms\n| `forms/` (dynamic form controls), `card-formgroup/`, `inline-edit/`,\n  `input-file/`, `radiobuttons/`, `checkbox-filters/`, `time-select-picker/`,\n  `editor/` (TinyMCE), `chip/`, `text-value/`, `drag-and-drop-list/`.\n\n| Filtering\n| `filters/`, `filtering-panel/`, `search-overlay/` (+ the 3.0 search engine\n  service and Elastic highlight handling).\n\n| Documents\n| `apps-document/`, `b2b-document/`, `pyb-documents/` — three document backends.\n\n| Platform\n| `interceptors/`, `cache/`, `feature-flags/`, `users/`, `resolvers/`, `guards`\n  (in `auth/` and the root `guards/`), `error-handling/`, `services/` (error\n  logger/parser/visualisation, cookies, long-term contracts, toastr mock),\n  `eva/`, `iframe/`, `sse.service.ts`, `monitor.service.ts`,\n  `version.service.ts`, `vpn-tester.service.ts`, `message.service.ts`,\n  `table-events.service.ts`, `logger.ts`, `dynamic-embedder.service.ts`.\n\n| Utilities\n| `utils/` (incl. the `utils` store slice and `route-access-resolver.ts`),\n  `functions/`, `enums/`, `consts/`, `interfaces/`, `models/`, `animations/`,\n  `csv-export-service/`, `lookup-service/`, `component-subscriber/`\n  (`SubscriberComponent` base class for subscription cleanup), `http-loader/`,\n  `loading-spinner/`, `spinner-button/`, `no-data/`, `task-count-badge/`,\n  `title-with-icon/`, `onboarding/`, `external-report/`, `reset-password/`,\n  `actions-menu/`, `actions-add-fabs/`.\n|===\n\nSee xref:platform:shared-library.adoc[the shared library reference].\n\n=== `pyb-material`\n\nA single module that imports and re-exports the ~29 Angular Material modules the\napp uses. Its purpose is stated in the repository README: consistent imports,\n\"important especially in unit tests\".\n\n=== `mocks`\n\nTest-only module with shared doubles and `pipes/`. Not for production paths.\n\n=== `guards` (root)\n\n`FeatureFlagsGeneralGuard` — initialises feature flags once the user is known, and\nin non-production logs the resolved flag table to the console.\n`SubPermissionsGuard` — checks `requireOneOfSubPermissions` from route data and\ntoasts \"Access denied.\" on failure.\n\nSee xref:platform:authorization.adoc[authorisation].\n\n== Where to put new code\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| You are adding… | Put it…\n\n| A new supplier tab\n| `suppliers/supplier/<tab-name>/`, with a route child under `:supplierKey` in\n  `suppliers/suppliers-routing.routes.ts`, guarded with the appropriate\n  `RESTRICTED_*_CONFIG` spread.\n\n| A new back-office screen\n| `manage/<screen>/`, route child under `/manage`.\n\n| A service used by one module\n| `<module>/services/` (or `<module>/service/` — both spellings exist; match the\n  module you are in).\n\n| A service used app-wide\n| `shared/services/<concern>/`, and provide it in `AppModule` if it must be a\n  singleton.\n\n| A model/DTO\n| `<module>/model/<concept>.model.ts` — one concept per file.\n\n| A reusable presentational component\n| `shared/<component>/`, declared and exported by `SharedModule`.\n\n| A new store slice\n| `<module>/store/`, then register the reducer in\n  `{source-root}/store/app.reducers.ts` and any effects class in\n  `EffectsModule.forRoot([...])` in `app.module.ts`. Only do this if more than one\n  screen needs the state.\n|===\n"},{"component":"internal-admin","module":"architecture","path":"architecture:bootstrap.adoc","title":"Bootstrap and composition root","description":"What happens between page load and the first rendered screen, and everything AppModule wires together.","url":"https://docx.payability.com/internal-admin/architecture/bootstrap.html","navOrder":8,"source":"= Bootstrap and composition root\n:description: What happens between page load and the first rendered screen, and everything AppModule wires together.\n\n== `main.ts`\n\n`src/main.ts` is deliberately small but does four non-obvious things:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Step | Detail\n\n| AG Grid licence\n| `LicenseManager.setLicenseKey(...)` is called *before* bootstrap with a licence\n  string embedded in source. The embedded key's `ExpiryDate` is in the past\n  (June 2021), which is why the console shows an AG Grid enterprise licence\n  warning; grid functionality still works.\n\n| Production mode\n| `enableProdMode()` when `environment.production` is true.\n\n| Deferred bootstrap\n| `main()` is exported and invoked either immediately (if\n  `document.readyState === 'complete'`) or on `DOMContentLoaded` — not the usual\n  unconditional call, because the app is embedded in pages that may already be\n  loaded.\n\n| Global error trap\n| `window.onerror` forwards uncaught errors to Segment as a `WINDOW ERROR` track\n  event, wrapped in `try/catch` so that a missing analytics object cannot break\n  the handler. This is separate from Angular's `ErrorHandler` — see\n  xref:platform:error-handling.adoc[error handling].\n|===\n\n== `AppModule`\n\n`{source-root}/app.module.ts` is the composition root. Everything is eager: there\nis no `loadChildren` in the live route tables, so the initial bundle contains the\nwhole application.\n\n=== Imports, in the order they matter\n\n[source,text]\n----\nAppWelcomeModule\nBrowserModule\nCancelEmitModule.forRoot()          module with a forRoot() singleton provider\nHttpModule                          legacy @angular/http — vestigial\nStoreModule.forRoot(APP_REDUCERS, { metaReducers: [storeDebugger] })\nEffectsModule.forRoot([ AuthEffects, GlobalSettingsEffects, SupplierEffects,\n                        CrmEffects, UtilsEffects, UnderwritingEffects ])\nStoreRouterConnectingModule\nStoreDevtoolsModule.instrument()    enabled unconditionally, prod included\nHttpClientModule\nBrowserAnimationsModule\nNgHttpLoaderModule                  global loading spinner\nAuthModule, ManageModule, MarketplacesModule, ReportsModule, AdminModule,\nSuppliersModule, ProcessesModule, SharedModule, CrmToolsModule\nMatSortModule\nNgxPermissionsModule.forRoot()\nDialogModule\nUnderwritingModule\nAppRoutesModule                     ← MUST be last: it owns the wildcard and\n                                      the default redirects\n----\n\n[WARNING]\n====\n`AppRoutesModule` is last in the import list by design — the comment in the source\nsays so. It declares `path: '**'` (→ `PageNotFoundComponent`) and the `''`\nredirect to `/welcome`. Importing a module that declares routes *after* it would\nmake those routes unreachable.\n====\n\n=== Effects registered at root\n\nOnly six effect classes are registered, all in `forRoot`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Class | Responsibility\n\n| `AuthEffects`\n| Google and basic login, token/user hydration, permission loading, logout and\n  storage cleanup, post-login redirect.\n\n| `GlobalSettingsEffects`\n| Fetching the global-settings collection.\n\n| `SupplierEffects`\n| Router-driven supplier loading, the supplier aggregate `forkJoin`, marketplaces,\n  payment configs, compliance documents, lifecycle toggles, card providers and\n  the Airwallex flows.\n\n| `CrmEffects`\n| CRM timeline loading and note/task/file/email mutations.\n\n| `UtilsEffects`\n| Reference-data lookups (timezones, funding periods, states, countries) with\n  in-state deduplication.\n\n| `UnderwritingEffects`\n| Underwriting slice loading.\n|===\n\n`BackgroundReportsEffects` exists as an empty file — the reducer is registered but\nthere are no effects. The calculator, layout, marketplace, onboarding and\nfetched-requests slices are reducer-only.\n\n=== Providers and DI substitutions\n\nThe provider list is where the abstract-service seams are bound. These are the\nsubstitutions to know:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Token | Implementation | Why it is a token\n\n| `HttpCacheAbstract`\n| `HttpCacheLocalStorage`\n| Lets the cache strategy be swapped (or stubbed in tests).\n\n| `AppConfigService`\n| `useFactory: initAppConfig`\n| Built from `environment` + `themeConfigData(environment.envName)` with a\n  hardcoded `'0.0.1'` version argument. The user-visible version comes from\n  `environment.VERSION` / `VersionService`, not from this argument.\n\n| `LookupAbstractService`\n| `LookupRepository`\n| Reference-data source; mock implementations exist for tests.\n\n| `ErrorHandler`\n| `AppErrorHandler`\n| Global Angular error handling.\n\n| `HTTP_INTERCEPTORS`\n| `AuthInterceptor`, then `FetchedRequestsInterceptor` (both `multi: true`)\n| Order matters — see xref:http-and-services.adoc#interceptors[the interceptor chain].\n\n| `PaymentConfigService`, `DepositAccountService`, `UsersService`\n| Themselves, via `useClass`\n| Declared as explicit `{ provide: X, useClass: X }` so that a spec can override\n  them by token without touching the class.\n|===\n\nPlain providers include `ErrorParserService`, `ErrorVisualizationService`,\n`ReceivableService`, `LayoutService`, `RouteAccessResolver`, `DatePipe`,\n`ReportsRepositoryService`, `NgxPermissionsService`, `DynamicEmbedderService`,\n`SupplierKeyResolver`, `ErrorLogger`, `FeatureFlagsGeneralGuard`,\n`SubPermissionsGuard`, `EvaCommunicationService` and `VpnTesterService`.\n\n=== A quirk in `AppModule`'s constructor\n\n`AppModule` itself has behaviour: `tryAddNoIndex()` inspects `window.location.href`\nfor any of `volusion`, `pro`, `lendio`, `neweggcapital`, `morecommerce`, `crm2`,\n`c2fo` and, if matched, injects `<meta name=\"robots\" content=\"noindex\">`. These are\nwhite-label supplier-site names; the goal is to keep white-labelled hosts out of\nsearch indexes.\n\n== `AppComponent`\n\n`{source-root}/app.component.ts` (selector `app-internal-client`) is the outermost\ncomponent and carries more responsibility than a typical shell. It extends\n`SubscriberComponent` (the shared base class that collects and unsubscribes\n`this.subscriptions`) and on init:\n\n. Builds `ThemeConfig` from `themeConfigData(env.envName)` — including the\n  `checkForSpecialOccasion()` seasonal theming hook.\n. Subscribes to the `auth` slice and calls\n  `NgxPermissionsService.loadPermissions(perms)` on every change. This is the\n  *second* place permissions are loaded (the first is `AuthEffects.handleFullLogin`),\n  which is what makes permissions survive a page reload from persisted auth state.\n. Applies a `restricted-access` CSS class to `document.body` when the user holds\n  `REST_UW_ACCESS` — a whole-app styling switch.\n. On every `NavigationEnd`: resets scroll, dispatches `LayoutUpdate(urlAfterRedirects)`\n  so the layout reducer can decide panel visibility, and notifies the EVA top-bar\n  iframe of the new parent route.\n. Subscribes to `incomingMessage$` and handles the inbound EVA protocol\n  (toasts, supplier-details store updates, store reads, navigation requests,\n  supplier-menu conditions). See\n  xref:platform:iframe-messaging.adoc[iframe messaging].\n. Pushes supplier details *out* to EVA whenever `selectSupplierDetails` emits.\n. Starts the VPN tester if `CHECK_VPN` is enabled.\n\n[NOTE]\n====\nBecause `AppComponent` both reads the store and talks to iframes, it is the single\nplace where the EVA seam and the NgRx state meet. Changes to the supplier-details\nmessage contract have to be made here and in `EvaCommunicationService` together.\n====\n\n== `MainLayoutComponent`\n\n`{source-root}/shared/layout/main-layout.component.ts` is the routed shell that all\nfeature routes render inside. It is intentionally thin: it subscribes to\n`LayoutService.customClasses$` and calls `detectChanges()`. The visual composition\n(sidebar, CRM panel, supplier header, outlet) lives in `main-layout.html`. See\nxref:ui-composition.adoc[UI composition].\n\n== The store debugger meta-reducer\n\n`{source-root}/store-debugger.ts`:\n\n[source,typescript]\n----\nexport const EnableStoreDebugging = false;\n\nexport const showStoreDebugger = () =>\n  environment.envName &&\n  environment.envName !== 'staging' &&\n  environment.envName !== 'prod' &&\n  EnableStoreDebugging;\n----\n\nIt logs every state/action pair to the console when enabled. It is off by default\nand requires a *source edit* to turn on — there is no runtime switch. Redux\nDevTools is the better tool for the same job and is always instrumented.\n\n== Bootstrap sequence summary\n\n[source]\n----\nindex.html\n  └─ main.ts\n       ├─ AG Grid licence\n       ├─ enableProdMode() if production\n       ├─ platformBrowserDynamic().bootstrapModule(AppModule)\n       │     ├─ AppConfigService built from environment\n       │     ├─ StoreModule.forRoot → auth reducer rehydrates from\n       │     │    localStorage['currentUser'] (isAuthenticated derived from it)\n       │     ├─ utils reducer rehydrates from lscache['utilsState']\n       │     ├─ supplier reducer restores localStorage['suppliers_last_query']\n       │     └─ EffectsModule.forRoot → six effect classes subscribe\n       ├─ AppComponent\n       │     ├─ loadPermissions from persisted auth\n       │     └─ iframe listeners, layout subscription\n       └─ Router evaluates the initial URL\n             └─ RouteAccessResolver → FeatureFlagsGeneralGuard →\n                NgxPermissionsGuard → resolvers → component\n----\n\nPersistence details — which keys, which TTLs — are in\nxref:platform:caching-and-persistence.adoc[caching and persistence].\n"},{"component":"internal-admin","module":"architecture","path":"architecture:routing.adoc","title":"Routing and navigation","description":"How URLs map to screens, how routes are admitted, and the conventions every route table follows.","url":"https://docx.payability.com/internal-admin/architecture/routing.html","navOrder":9,"source":"= Routing and navigation\n:description: How URLs map to screens, how routes are admitted, and the conventions every route table follows.\n\n== Configuration\n\nAll routing is configured once, in `{source-root}/app-routes.module.ts`:\n\n[source,typescript]\n----\nRouterModule.forRoot(generalAppRoutes, {\n  enableTracing: false,\n  scrollPositionRestoration: 'enabled',\n  useHash: true,\n  relativeLinkResolution: 'legacy',\n})\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Option | Consequence\n\n| `useHash: true`\n| URLs are `https://host/#/suppliers/list`. Anything that manipulates URLs by\n  hand must account for the `#` — see xref:#deep-links[deep links and redirects].\n\n| `scrollPositionRestoration: 'enabled'`\n| The router restores scroll on back/forward. `AppComponent` *also* resets\n  `document.body.scrollTop` on `NavigationEnd`.\n\n| `relativeLinkResolution: 'legacy'`\n| Angular 12 compatibility flag; relative `routerLink` resolution follows the\n  pre-v11 rules. Changing it would silently alter many relative links.\n\n| `enableTracing: false`\n| Router tracing off; use `@ngrx/router-store` actions in DevTools instead.\n|===\n\n== Route aggregation\n\nEach feature module exports a plain `Routes` array from a `*-routing.routes.ts`\nfile. The root module imports them all and nests them under the layout shell:\n\n[source,typescript]\n----\nexport const generalAppRoutes: Routes = [\n  {\n    path: '',\n    component: MainLayoutComponent,\n    children: [\n      ...welcomeRoutes, ...adminRoutes, ...tasksRoutes, ...emailRoutes,\n      ...manageRoutes, ...marketplacesRoutes, ...processesRoutes,\n      ...reportsRoutes, ...supplierRoutes, ...UwRoutes,\n      redirectToWelcomeRoute,           // '' → /welcome\n    ],\n  },\n  redirectToWelcomeRoute,\n  { path: '**', component: PageNotFoundComponent },\n];\n----\n\nTwo modules opt out of the aggregate and declare their own child routers:\n\n* `auth/auth-routing.module.ts` — `/login`, `/logout`. These render *outside*\n  `MainLayoutComponent`, which is why the login page has no sidebar.\n* `shared/shared-routing.module.ts` — `/reset-password`.\n\n[TIP]\n====\nAdding a top-level area means: create `<module>/<module>-routing.routes.ts`\nexporting `routes`, then spread it into `generalAppRoutes`. Do not add a second\n`forRoot`.\n====\n\n== Route admission\n\n=== Guard order\n\nThe order guards are listed in `canActivate` is the order they run, and it is\nconsistent across every feature route table:\n\n[source,typescript]\n----\ncanActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard, NgxPermissionsGuard]\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Guard | What it does\n\n| `RouteAccessResolver`\n| Despite the name it is a `CanActivate` guard, not a resolver. Selects\n  `selectLoggedUserModel`; rejects when there is no user, when the user has no\n  `loginId`, or when the JWT's `token` claim is in the past. On rejection it\n  stores the current URL in `sessionStorage` under `loginRedirectUrl`, dispatches\n  `Logout` and navigates to `/login`. Implementation:\n  `{source-root}/shared/utils/route-access-resolver.ts`.\n\n| `FeatureFlagsGeneralGuard`\n| Waits for the `auth` slice to carry a user, then runs\n  `FeatureFlagsService.initFF()` — reconciling build flags, global-settings flags\n  and session overrides. Always resolves `true` (errors are swallowed with\n  `catchError(() => of(true))`) so a flag-service outage cannot lock users out. In\n  non-production it prints the resolved flag table to the console once.\n  Implementation: `{source-root}/guards/feature-flags-general.guard.ts`.\n\n| `NgxPermissionsGuard`\n| Evaluates `data.permissions` — `{ only: … }` or `{ except: [ … ] }`, optionally\n  with `redirectTo`.\n|===\n\nTwo additional guards appear on specific routes:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Guard | Used by\n\n| `FeatureFlagGuard`\n| Routes whose `data.enabledByFeatureFlag` names a flag. Implements both\n  `CanActivate` and `CanLoad`; navigates to `/404` when the flag is off. Used on\n  `suppliers/:key/new-underwriting` and\n  `suppliers/:key/underwriting/documentation`.\n\n| `SubPermissionsGuard`\n| Routes whose `data.requireOneOfSubPermissions` lists sub-permissions. Checks\n  each with `UsersService.checkPermission` and passes if *any* holds; otherwise\n  toasts \"Access denied.\" and navigates to `/`. Used on the Amazon-lending\n  add-loan and edit-loan routes (`MP_LOANS_MODIFY`, `MP_LOANS_DELETE`).\n\n| `AuthGuard`\n| Only on `/welcome`. A simpler check than `RouteAccessResolver` — it just\n  redirects to `/login` when `isLoggedIn` is false.\n|===\n\n[WARNING]\n====\n`FeatureFlagGuard` is listed in both `canLoad` and `canActivate` on those routes.\n`canLoad` only fires for lazily-loaded children; because nothing is lazily loaded\nhere, only `canActivate` has an effect. Harmless, but do not read it as evidence of\nlazy loading.\n====\n\n=== Permission clause conventions\n\nMost feature areas use the same *deny* clause:\n\n[source,typescript]\n----\ndata: { permissions: { except: [USER_PERMISSION.REST_UW_ACCESS,\n                                USER_PERMISSION.ADVANCE_AUDITORS] } }\n----\n\nThe suppliers module formalises the variants as reusable spreads in\n`suppliers/suppliers-routing.routes.ts`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Constant | Denies\n\n| `RESTRICTED_UW_ACCESS_CONFIG`\n| `REST_UW_ACCESS` and `ADVANCE_AUDITORS` (the common case).\n\n| `RESTRICTED_AUDITORS_ACCESS_CONFIG`\n| `REST_UW_ACCESS` only — auditors *are* allowed (used on `general`).\n\n| `RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG`\n| `ADVANCE_AUDITORS` only — restricted UW users *are* allowed (used on\n  `underwriting`).\n\n| `RESTRICTED_UW_ACCESS_CONFIG_MARKETPLACE_STATEMENTS`\n| As `RESTRICTED_UW_ACCESS_CONFIG`, plus report metadata in `data`\n  (`reportName: 'mp_statements'`, `reportConfigName: 'v_mp_statements'`,\n  `supplierKeyInQueryParamsNotNeeded: true`).\n|===\n\nThese are spread *after* the route's own properties, so a route that spreads one of\nthem and also declares its own `data` will have the spread win. Watch for that when\nadding route data to a restricted route.\n\nOnly two routes use an *allow* clause: `/admin` (`only: DEV`) and\n`suppliers/:key/underwriting/background-reports` (`only: FINANCE`).\n\nThe full table is in xref:reference:routes.adoc[the route catalogue]; the\nmechanics are in xref:platform:authorization.adoc[authorisation].\n\n== Route data as configuration\n\nRoute `data` is used as a lightweight configuration channel, not only for\npermissions:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Meaning\n\n| `permissions`\n| `ngx-permissions` clause.\n\n| `enabledByFeatureFlag`\n| Flag name checked by `FeatureFlagGuard`.\n\n| `requireOneOfSubPermissions`\n| Sub-permission list checked by `SubPermissionsGuard`.\n\n| `reportId`\n| Which external report an `UnderwritingReportTabComponent` instance renders —\n  `ExternalReports.PLAID`, `BANK_CATEGORIES`, `ACCESS_ONBOARDING_PREDICTIONS`,\n  `ACCESS_DAILY_PREDICTIONS`. One component, four routes.\n\n| `reportName`, `reportConfigName`\n| Which internal report/view a report component renders.\n\n| `supplierKeyInQueryParamsNotNeeded`\n| Suppresses the supplier-key query-parameter requirement for report tables.\n\n| `isNew`\n| Distinguishes `additional-business-owners/new` from the list route using the\n  same component.\n|===\n\nThis \"one component, many routes, differentiated by `data`\" pattern is used\ndeliberately for report tabs and add/edit pairs. When you see two routes pointing\nat the same component, check `data` before assuming duplication.\n\n== Resolvers\n\nOnly one resolver is in live use:\n\n`SupplierKeyResolver` (`{source-root}/shared/resolvers/supplier-key.resolver.ts`)::\nReads `supplierKey` from `route.parent.params`, falling back to\n`route.parent.parent.params`, and *dispatches* `SetSupplierKey` rather than\nreturning fetched data. It is attached to the `suppliers/:supplierKey` route so\nthat the store points at the right supplier before any child component renders.\n`SupplierKeyMockResolver` is its test double.\n\nDomain data loading is *not* done in resolvers — it is done in\n`SupplierEffects.navigateToSupplier`, which reacts to `ROUTER_NAVIGATION`. See\nxref:state-management.adoc#router-driven[router-driven state].\n\n[[deep-links]]\n== Deep links, redirects and the hash\n\nBecause `useHash` is on, URL handling in code has to strip the fragment.\n`RouteAccessResolver.handleLogout()` does this:\n\n[source,typescript]\n----\nconst backUrl = window.location.href\n  .replace(window.location.origin, '')\n  .replace(/^\\/#\\/(.*)$/, '/$1');\nif (this.loginPageRoute !== backUrl) {\n  sessionStorage.setItem(LOGIN_REDIRECT_URL, backUrl);\n}\n----\n\nAfter a successful login, `AuthEffects.login` reads `sessionStorage['loginRedirectUrl']`\n(defaulting to `/`) and navigates inside `ngZone.run(...)` — required because the\nGoogle Identity callback resolves outside Angular's zone.\n\nNote that `loginRedirectUrl` is *not* cleared after use; a later logout overwrites\nit. See xref:platform:authentication.adoc[authentication].\n\n== Redirect conventions\n\nEvery area follows the same shape:\n\n[source,typescript]\n----\n{ path: '', redirectTo: 'list', pathMatch: 'full' },\n{ path: 'list',            component: XListComponent },\n{ path: 'add' | 'new',     component: XAddComponent },\n{ path: ':key/edit',       component: XEditComponent },\n----\n\nDeviations worth knowing:\n\n* `suppliers` redirects `''` to `'../'` — i.e. out of the suppliers area entirely.\n* `admin` uses a `**` child redirect to `/admin/feature-flags` instead of a `''`\n  redirect.\n* `tasks` and `emails` redirect to a parameterised default:\n  `list/0/filter` and `list` respectively.\n* `processes` has an ordering hazard: `:processKey/:paymentKey` and `:supKey` are\n  both declared before `approval`, so `/processes/approval` matches `:supKey`\n  first. This is why the sidebar links to `/proceses/approval` (the second,\n  misspelled route table) for Balance Approval. Do not reorder without checking\n  both.\n\n== Navigation UI\n\nThe sidebar (`{source-root}/shared/sidenav/`) is a hand-maintained mirror of the\nroute table, not generated from it. Menu items are wrapped in\n`*ngxPermissionsExcept` / `*ngxPermissionsOnly` and, where relevant, in\n`*appEnabledByFeatureFlag`.\n\n[IMPORTANT]\n====\nAdding a route does not add a menu entry, and the permission clause on the route\nand on the menu item are maintained separately. When you add or restrict a route,\nupdate `sidenav-menu.component.html` to match — a mismatch shows users a link that\nimmediately bounces them.\n====\n\nLayout reacts to the URL through the `layout` slice, which matches\n`urlAfterRedirects` against regexes for `supplierList`, `supplierGeneral` and\n`supplierDefault` to decide whether the CRM panel and supplier-info panel are\nhidden, closed, open or fixed. See xref:ui-composition.adoc[UI composition].\n"},{"component":"internal-admin","module":"architecture","path":"architecture:state-management.adoc","title":"State management (NgRx)","description":"The NgRx 4 patterns in use, every state slice, and the idioms to copy.","url":"https://docx.payability.com/internal-admin/architecture/state-management.html","navOrder":10,"source":"= State management (NgRx)\n:description: The NgRx 4 patterns in use, every state slice, and the idioms to copy.\n\n[IMPORTANT]\n====\nThis project uses NgRx {ngrx-version}. `createAction`, `createReducer`,\n`createEffect`, `@ngrx/entity` and the `ActionCreator` type *do not exist* in this\nversion. Copy the existing patterns; do not import modern NgRx idioms.\n====\n\n== The NgRx 4 idioms\n\n=== Actions\n\nAction types are exported string constants; actions are classes implementing\n`Action` with a `readonly type`; a union type collects them for the reducer.\n\n[source,typescript]\n----\n// suppliers/store/supplier/supplier.actions.ts\nexport const FETCH_SUPPLIER_DETAILS = 'FETCH_SUPPLIER_DETAILS';\n\nexport class FetchSupplierDetails implements Action {\n  readonly type = FETCH_SUPPLIER_DETAILS;\n  constructor(public payload: { supplierKey: string; refresh?: boolean }) {}\n}\n\nexport type Actions = FetchSupplierDetails | UpdateSupplierModel | /* … */;\n----\n\nConventions actually followed:\n\n* Type strings are `SCREAMING_SNAKE_CASE` with no `[Feature]` prefix.\n* The triplet convention is `X`, `X_SUCCESS`, `X_ERROR` — the base type is the\n  *request*, not the result.\n* Some actions carry data outside `payload` — `UpdateSupplierModel` has a second\n  constructor parameter `supplierKey`, and `UpdateCrmModel` has `targetUuid` and\n  `alterKeys`. Reducers read `action.supplierKey` directly. Unusual, but load-bearing.\n* Base classes are used to share payload shapes: `CrmItemService`,\n  `CrmTaskService`, `CrmFetchListService` in `crm.actions.ts`.\n\n=== Selectors\n\nSelectors live *in the actions file*, not a separate `selectors.ts`, except in the\nnewer slices (`global-settings`, `underwriting`) which do have\n`*.selectors.ts`. Both conventions are present; match the slice you are editing.\n\n[source,typescript]\n----\nconst selectAuth = (state: AppState): AuthState => state.auth;\n\nexport const selectAuthToken = createSelector(selectAuth, (state) => {\n  if (hasCorrectToken(state)) { return state.user.jwtToken; }\n});\n----\n\nNote the pattern of returning `undefined` implicitly rather than `null` when the\nguard fails — consumers therefore use `filter(Boolean)` or `filter(x => !!x)`\ndownstream rather than null checks.\n\nA selector factory pattern appears twice and is worth knowing:\n\n[source,typescript]\n----\n// utils.actions.ts — one selector per reference-data attribute\nfunction selectUtilsAttribute(attr: string) {\n  return createSelector(selectUtilsState, (state) =>\n    state?.hasOwnProperty(attr) ? state[attr] : undefined);\n}\nexport const selectTimezones = selectUtilsAttribute('timezones');\n\n// crm.actions.ts — one selector per timeline stream\nfunction createTimelineStreamSelector(streamName: string) { /* … */ }\nexport const selectCrmNotes = createTimelineStreamSelector('notes');\n----\n\n=== Reducers\n\nPlain functions with a `switch`. Two things to watch:\n\n* Several reducers use `const` declarations inside `case` blocks without braces, so\n  all the `const`s share the `switch` block scope. This compiles but means a name\n  can only be used once per reducer.\n* `supplier.reducers.ts` mutates model objects in places\n  (`supplierDetailsModel.hide = !supplierDetailsModel.hide`,\n  `supplierModel.marketplaces[index] = …`, `delete state.supplierModel.marketplaces`)\n  while spreading the surrounding state. This breaks strict immutability. It works\n  because `ChangeDetectionStrategy.OnPush` is not used broadly, but it is a real\n  hazard: do not rely on reference equality of nested supplier objects.\n\nOne slice uses a *map-of-handlers* instead of a switch —\n`shared/interceptors/fetched-requests.reducers.ts` builds a `Map` from action type\nto handler function. A third style; not worth propagating.\n\n=== Effects\n\n[source,typescript]\n----\n@Injectable()\nexport class SupplierEffects {\n  @Effect()\n  getSupplierDetails = this.actions$.ofType(FETCH_SUPPLIER_DETAILS).pipe(\n    withLatestFrom(this.store$),\n    switchMap(([action, state]) => /* … */),\n  );\n\n  @Effect({ dispatch: false })\n  authLogout = this.actions$.ofType(LOGOUT).pipe(tap(/* side effect only */));\n}\n----\n\nRecurring idioms:\n\nCache-check with `withLatestFrom(this.store$)`::\nNearly every fetch effect pulls the whole `AppState`, checks whether the aggregate\nis already present, and returns `of()` (an empty observable) if it is — unless\n`payload.refresh` is set. This is the app's caching strategy for domain data.\n\nReturning plain action literals::\nEffects frequently emit `{ type: UPDATE_SUPPLIER_MODEL, supplierKey, payload }`\nobject literals rather than constructing action classes. Both forms coexist.\n\nFan-out from one action::\n`SupplierEffects.navigateToSupplier` returns an *array* of three actions from\n`mergeMap`, which NgRx dispatches in order. This is how a supplier URL\nsimultaneously repoints the supplier slice, the CRM slice and triggers the load.\n\n`forkJoin` for aggregate loads::\nThe supplier aggregate is four parallel calls; permission-dependent ones are\nreplaced with `of(null)` rather than being conditionally excluded, so the tuple\nshape stays fixed.\n\nError handling inside the stream::\n`catchError` returns an `_ERROR` action rather than rethrowing, so the stream\nsurvives. Errors are also logged through the `Logger` helper.\n\n== `AppState`\n\n`{source-root}/store/app.reducers.ts` defines the entire root state — 12 slices,\nnothing lazily added:\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Slice | Reducer | Holds\n\n| `auth`\n| `authReducers`\n| `isAuthenticated`, the raw `user` object (including `jwtToken` and `perms`), and\n  `authError`. Rehydrated from `localStorage['currentUser']` at reducer-init time.\n\n| `globalSettings`\n| `globalSettingsReducers`\n| The global key/value configuration collection plus a `loading` flag. Has its own\n  `*.selectors.ts` and `*.state.ts`.\n\n| `layout`\n| `layoutReducer`\n| Shell presentation: CRM panel state, supplier-info panel state, search-button\n  and actions visibility, chosen search engine, app-stage label, current route,\n  and a per-route-pattern `userLayoutConfig`.\n\n| `supplier`\n| `supplierReducers`\n| The current supplier plus a `suppliers` cache keyed by supplier key, the\n  part-wise edit maps, per-operation progress/error flags, payment-config\n  statuses, card providers, supplier cards and Airwallex state. Also persists the\n  last supplier-list query to `localStorage`.\n\n| `crm`\n| `crmReducers`\n| Normalised CRM timeline: `crmItems` (uuid → item), per-type uuid streams, a\n  per-target cache, the current `targetUuid`, and pending-item statuses.\n\n| `utils`\n| `utilsReducers`\n| Reference data (timezones, funding periods, US states, countries), persisted to\n  `localStorage` via `lscache` with a 24-hour TTL.\n\n| `calc`\n| `calculatorReducers`\n| The advance-offer pricing calculator: periods, requested/recommended/approved\n  amounts, totals, validation flags, risk rating, grace period, return strategy.\n  The largest piece of pure business arithmetic in the front end.\n\n| `marketplace`\n| `marketplacesReducer`\n| Marketplace list state.\n\n| `onboarding`\n| `onboardingReducer`\n| Onboarding template state.\n\n| `errors`\n| `errorsReducer` (`IFetchedRequestsState`)\n| A ring buffer of the last 50 HTTP requests, serialised, for debugging.\n\n| `backgroundReports`\n| `backgroundReportsReducer`\n| Underwriting background-report state. Reducer-only — the effects file is empty.\n\n| `underwriting`\n| `underwritingReducers`\n| The newer underwriting slice, with `*.state.ts` and `*.selectors.ts`.\n|===\n\nThe per-slice action catalogue is in\nxref:reference:store-actions.adoc[the store action catalogue].\n\n== What is *not* in the store\n\nMost feature state is component-local: report tables, manage screens, marketplace\nforms, most dialogs, all grid state. The store is reserved for state that crosses\nscreens.\n\nTwo non-NgRx sharing mechanisms exist alongside it:\n\n`TableEventsService`::\nA `Subject`-based publish/subscribe bus with string event names —\n`ADD_TRANSACTION_EVENT`, `CRM_OPEN_ALL_TAB`, `SIDENAV_CLOSED_EVENT`,\n`TASK_COUNTERS_UPDATE`. Used where a store action would be overkill. See\nxref:platform:realtime.adoc#in-app-bus[the in-app event bus].\n\n`BehaviorSubject` caches in services::\n`UsersService` caches users/groups in `BehaviorSubject`s;\n`FeatureFlagsService` caches global-settings flags the same way;\n`GlobalSettingsService` reads *through* the store but is itself the API. This means\nsome shared state lives in services rather than the store — check the service\nbefore adding a slice.\n\n[NOTE]\n====\n`GlobalSettingsService` both dispatches store actions and returns observables\nderived from the store. It is a facade over the slice, so call the service; do not\nselect `globalSettings` directly from a component.\n====\n\n[[router-driven]]\n== Router-driven state\n\n`StoreRouterConnectingModule` emits `ROUTER_NAVIGATION`. Two consumers matter:\n\n`SupplierEffects.navigateToSupplier`::\nWalks the activated-route tree looking for a `supplierKey` parameter (a recursive\n`filter(activatedRoot, NEEDED_KEY)` helper) and, when found, dispatches\n`SET_SUPPLIER_KEY`, `SET_TARGET_UUID` and `FETCH_SUPPLIER_DETAILS`. This is why a\nsupplier page needs no explicit load call in its component.\n\n`layout` slice::\nFed by `LayoutUpdate(urlAfterRedirects)` dispatched from `AppComponent` on\n`NavigationEnd` (not from router-store), and matches the URL against regexes to\nselect a `userLayoutConfig` entry.\n\n== Persistence in reducers\n\nThree reducers read and write browser storage *at module-evaluation time or inside\nthe reducer body*, which is unusual and worth flagging:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Reducer | Storage behaviour\n\n| `authReducers`\n| Default state comes from `getPersistedState()`, which parses\n  `localStorage['currentUser']`. `LOGIN` writes it back. `LOGOUT`/`CLEAR_AUTH_STATE`\n  reset to `initialState` — but the actual `localStorage.clear()` happens in\n  `AuthEffects`, not the reducer.\n\n| `supplierReducers`\n| Initial `lastQueryString` comes from `localStorage['suppliers_last_query']`, or\n  falls back to `created_since:<today − 1 day>`. `SET_SUPPLIERS_QUERY_STRING`\n  writes it back.\n\n| `utilsReducers`\n| Default state comes from `lscache.get('utilsState')`; `SET_UTIL_ATTR` saves the\n  whole slice back with a 24-hour TTL.\n|===\n\nFull key list: xref:platform:caching-and-persistence.adoc[caching and persistence].\n\n== Adding to the store\n\nOnly if more than one screen needs the state. Then:\n\n. Create `<module>/store/<slice>.actions.ts` with type constants, action classes\n  and a union type. Put selectors here unless the slice is new enough to warrant\n  a `*.selectors.ts`.\n. Create `<module>/store/<slice>.reducers.ts` with an exported state interface,\n  an `initialState` and a `switch` reducer.\n. If it needs I/O, create `<module>/store/<slice>.effects.ts` with `@Effect()`\n  properties and `this.actions$.ofType(...)`.\n. Register the reducer in `AppState` and `APP_REDUCERS` in\n  `{source-root}/store/app.reducers.ts`.\n. Register the effects class in `EffectsModule.forRoot([...])` in\n  `{source-root}/app.module.ts`.\n. Add a `*-mock.reducers.ts` if specs will need a stub — the existing slices all\n  have one (`auth-mock.reducers.ts`, `crm-mock.reducers.ts`,\n  `supplier-mock.reducers.ts`, `utils-mock.reducets.ts` — note the typo in that\n  filename).\n\nTesting patterns are in xref:ops:testing.adoc[testing]; the calculator slice has\nthe most thorough spec (`calculator.spec.ts`, ~560 lines) and is the best model to\ncopy.\n"},{"component":"internal-admin","module":"architecture","path":"architecture:http-and-services.adoc","title":"HTTP layer and services","description":"How the application talks to its backends — interceptors, service conventions, abstractions and error mapping.","url":"https://docx.payability.com/internal-admin/architecture/http-and-services.html","navOrder":11,"source":"= HTTP layer and services\n:description: How the application talks to its backends — interceptors, service conventions, abstractions and error mapping.\n\n== Service conventions\n\nA service is a thin `@Injectable` wrapper over one backend concern. The canonical\nshape:\n\n[source,typescript]\n----\n@Injectable()\nexport class SupplierEmailPreferencesService {\n  private readonly apiUrl: string;\n\n  constructor(private appConfig: AppConfigService, private http: HttpClient) {\n    this.apiUrl = appConfig.env.internalApiUrl;      // never hardcoded\n  }\n\n  getPreferences(supplierKey: string): Observable<EmailPreference[]> {\n    return this.http\n      .get<EmailPreferenceDto[]>(`${this.apiUrl}suppliers/${supplierKey}/email_preferences`)\n      .pipe(map((dto) => dto.map(toEmailPreference)));   // shape mapping in the service\n  }\n}\n----\n\nRules:\n\n. **Base URLs come from `AppConfigService`.** `appConfig.env.<someApiUrl>` — one of\n  the ~15 keys in `IEnvironment`. Never a literal host.\n. **Return `Observable<T>`.** No promises, with one exception:\n  `FeatureFlagsService` uses `async`/`await` and `.toPromise()` internally because\n  flag initialisation is awaited by a guard.\n. **Map the response shape inside the service.** Components should receive models,\n  not raw API payloads.\n. **One concern per service.** `SupplierService` is the counter-example — it has\n  grown to cover details, tokens, reports, balances, advances, health and\n  statements; do not extend it further.\n\n=== Base URL keys and their meaning\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `env` key | Backend\n\n| `internalApiUrl`\n| The primary internal API (`database_v2/internal`). Suppliers, payments, fees,\n  reports, users, permissions, lookups, marketplace links, UCC, advances.\n\n| `cliApiUrl`\n| The CLI service (`database_v2/cli`) — bulk/scripted operations.\n\n| `supWidgetsApiUrl`\n| Supplier widget endpoints used by onboarding.\n\n| `authToolsApiUrl`\n| Supplier auth tokens and SMS number provisioning.\n\n| `actionsToolsUrl`\n| CRM activity (notes, tasks, files) and global settings.\n\n| `eventLoggerUrl`\n| Domain event stream and the Elasticsearch changelog.\n\n| `eventRulesOldUrl`\n| Event rules v1 plus its Elasticsearch index.\n\n| `messagingUrl`\n| Outbound email/SMS.\n\n| `workflowApiUrl` + `workflowGciId`\n| B2B task manager — long-running jobs and the SSE progress stream.\n\n| `b2bDocumentsApiUrl`, `pybDocumentsApiUrl`, `documentsApiUrl`, `s3FilesProviderApi`\n| Four document/file backends, used by different features.\n\n| `marketplaceApiUrl`\n| Marketplace service (supplier-marketplace data).\n\n| `automaticMarketplaceApiUrl`\n| Automatic underwriting tool — real-time validation and UW data.\n\n| `evaApiUrl`\n| The EVA micro-front-end origin (iframe `src`, not an HTTP API).\n\n| `supplierUrl`, `supplierReferralUrl`\n| Supplier self-service site (login-as-supplier) and referral link base.\n|===\n\nWhich service uses which is enumerated in\nxref:reference:services-and-endpoints.adoc[services and endpoints]; the wider\npicture is in xref:integration-landscape.adoc[the integration landscape].\n\n[[interceptors]]\n== The interceptor chain\n\nTwo interceptors are registered in `AppModule`, in this order:\n\n[source,typescript]\n----\n{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor,            multi: true },\n{ provide: HTTP_INTERCEPTORS, useClass: FetchedRequestsInterceptor, multi: true },\n----\n\nand a third is contributed by `NgHttpLoaderModule` via `useExisting`.\n\n=== `AuthInterceptor`\n\n`{source-root}/auth/auth-interceptor.ts`\n\n* Reads the `auth` slice with `take(1)` on every request.\n* If `hasCorrectToken(authState)`, clones the request with the\n  `X-Session-Token: <jwt>` header. Header name is exported as `AUTH_HEADER_NAME`.\n* Catches `HttpErrorResponse`; on `401` *with* a token present it logs and\n  dispatches `Logout({ message: 'Auth token expired, please try to login again.' })`,\n  then rethrows.\n* If there is no valid token, the request passes through unmodified — which is how\n  the login call itself works (it sends its own `Authorization: Basic` header).\n\n[NOTE]\n====\nBecause the interceptor selects the store per request, an in-flight token change is\npicked up by subsequent requests but not by requests already dispatched. Nothing\nrefreshes a token: expiry is detected by `RouteAccessResolver` on navigation and by\nthe 401 trap here.\n====\n\n=== `FetchedRequestsInterceptor`\n\n`{source-root}/shared/interceptors/fetched-requests.interceptor.ts` dispatches\n`FetchRequest(req)` for every request and forwards it untouched. The `errors`\nreducer keeps the last 50 requests, `JSON.stringify`-ed, in state.\n\nThis is purely a debugging aid — it is what the error-handling UI shows as recent\nrequest context. It also means *every* request body passes through the store, so\navoid dispatching sensitive payloads through paths you would not want in a\nDevTools snapshot.\n\n=== `PendingInterceptorService`\n\n`{source-root}/shared/http-loader/pending-interceptor.service.ts` (a vendored\n`ng-http-loader`):\n\n* Counts in-flight requests and pushes `true`/`false` to `pendingRequestsStatus`,\n  which drives `SpinnerComponent` / `PreloaderDirective`.\n* Additionally toasts any response with `status >= 500` as\n  `\"<status>: <message>\"`, then rethrows.\n\nSo a 5xx produces a toast *here*, and any error the calling code passes to\n`ErrorVisualizationService` produces a second one. Duplicate toasts on server\nerrors are expected behaviour, not a bug to chase.\n\n=== `CachingInterceptor` — present but not registered\n\n`{source-root}/shared/cache/caching-interceptor.ts` implements read-through caching\nfor `GET` requests against `HttpCacheAbstract`. It is *not* in `AppModule`'s\n`HTTP_INTERCEPTORS` list, so it is inactive globally; `HttpCacheAbstract` →\n`HttpCacheLocalStorage` is still provided and can be injected directly.\n\nIf you enable it, note the 5-minute TTL and MD5 cache key in\n`HttpCacheLocalStorage`, and that it caches *all* GETs including permission- and\nsupplier-scoped ones. See\nxref:platform:caching-and-persistence.adoc[caching and persistence].\n\n[[abstractions]]\n== Service abstractions and mocks\n\nEleven abstract base classes define seams that specs and alternative\nimplementations plug into:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Abstract class | Live implementation\n\n| `HttpCacheAbstract`\n| `HttpCacheLocalStorage` (bound in `AppModule`)\n\n| `LookupAbstractService`\n| `LookupRepository` (bound in `AppModule`)\n\n| `UsersAbstractService`\n| `UsersService`\n\n| `ConfigsAbstractService`\n| `ConfigsService`\n\n| `TasksAbstractService`\n| `TasksService`\n\n| `MarketplacesAbstractService`\n| `MarketplaceRepository`\n\n| `PaymentBatchesAbstractService`\n| `PaymentBatchRepositoryService`\n\n| `PaymentBatchDownloaderAbstractService`\n| `PaymentBatchDownloaderService`\n\n| `ReportsAbstractService`\n| `ReportsRepositoryService`\n\n| `LongTermContractsAbstractService`\n| `LongTermContractsService`\n|===\n\nAlongside these, ~30 services ship a `*-mock.service.ts` sibling\n(`supplier-mock.service.ts`, `crm-mock.service.ts`, `tasks-mock.service.ts`,\n`payment-config-mock.service.ts`, `b2b-document-mock.service.ts`, …). They are the\ntest doubles; specs provide them by token. They must never be imported from\nproduction code paths — see xref:ops:testing.adoc[testing].\n\nThe `ServiceUpdatePartialInterface`\n(`shared/interfaces/service-update-partial-interface/`) declares\n`saveItemPart(itemKey, itemPart, ...params)`. Services that back the supplier\nworkspace's card-by-card editing implement it, which is what lets the\n`UPDATE_MODEL_PART` effect call any of them uniformly. See\nxref:domains:suppliers.adoc#partial-editing[partial editing].\n\n== Error mapping\n\nServices do not surface raw `HttpErrorResponse` objects to components. Three\npatterns are in use, oldest first:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Pattern | Where\n\n| `catchError(err => ErrorObservable.create(err))`\n| Older services (`SupplierService`, `LookupRepository`). A deep legacy RxJS\n  import; equivalent to `throwError`.\n\n| `catchError(err => observableThrowError(err))` with a toast\n| Repository services (`PaymentBatchRepositoryService` toasts\n  \"Cannot fetch Payment Batches\" then rethrows).\n\n| Return an `_ERROR` action from an effect\n| Store-backed flows. The component reads the error out of state.\n|===\n\nFor user-facing presentation, everything funnels through\n`ErrorParserService` → `ErrorVisualizationService`, which understands the internal\nAPI's error envelope (`error.meta.errors[].message`) as well as plain\n`{ message }` shapes. Details in xref:platform:error-handling.adoc[error handling].\n\n== Request shaping conventions\n\nQuery parameters::\nBuilt with `HttpParams` and `.set()`/`.append()` chaining, never string\nconcatenation. Grid-driven endpoints take `limit`/`offset` plus arbitrary filter\nkeys (`BalanceApprovalService.getData` copies a `DataTableQueryParams` object into\nparams, skipping `limit`/`offset`).\n\nCache-busting::\n`refreshTimestamp=Date.now()` is appended when a caller asks for a refresh\n(`SupplierService.fetchSupplierDetails`).\n\nCustom headers::\nSeveral backends need extra headers, set per service rather than in an interceptor:\n`X-InstanceId` (event rules, global settings — value from `env.instanceId`, 8001),\n`X-EventLogger-InstanceId`, `X-EventLogger-Type`, `X-EventLogger-TargetUUID`,\n`X-Actions-TargetUUID` (CRM). `CrmService` keeps the target-UUID header in sync by\n*subscribing to the store* in its constructor.\n\nFile uploads::\n`FormData` built by a shared helper (`BatchImportService.getFormData`), posted to\n`*_csv_upload` endpoints.\n\nDownloads::\n`responseType: 'text'` for report dumps, `file-saver` for writing the file. See\n`CsvExportService` and `PaymentBatchDownloaderService`.\n\n[WARNING]\n====\n`MonitorService.getHeaders()` contains a hardcoded\n`Authorization: Token <literal>` and `X-EventLogger-InstanceId: 8003` for the\nanalytics event-logger call. It is the one place a credential is embedded in\nsource. Treat it as known debt; do not copy the pattern.\n====\n"},{"component":"internal-admin","module":"architecture","path":"architecture:data-modeling.adoc","title":"Data modelling and serialisation","description":"How API payloads become application objects, the cerialize casing mechanics, and the three model styles in the codebase.","url":"https://docx.payability.com/internal-admin/architecture/data-modeling.html","navOrder":12,"source":"= Data modelling and serialisation\n:description: How API payloads become application objects, the cerialize casing mechanics, and the three model styles in the codebase.\n\n== The casing problem\n\nThe backends speak `snake_case`; the application speaks `camelCase`. Sometimes.\nNot always. This is the single most error-prone area of the codebase, and it is\nworth understanding precisely.\n\n`cerialize` provides the translation, but the casing setting is *global and\nimperative*, not per-model:\n\n[source,typescript]\n----\n// SupplierService.fetchSupplierDetails\nreturn this.httpClient.get<SupplierDetailsModel>(url, { params }).pipe(\n  map((result) => {\n    DeserializeKeysFrom(CamelCase);        // ← global switch, set per call\n    return Deserialize(result, SupplierDetailsModel);\n  }),\n);\n\n// CrmService.listActions\nmap((res) => {\n  DeserializeKeysFrom(UnderscoreCase);    // ← the other direction\n  return { data: res['data'].map(/* … */), meta: Deserialize(res['meta'], CrmMetaModel) };\n})\n\n// PendingOfferRiskRatingService.prepareDataToSave\nSerializeKeysTo(UnderscoreCase);\nconst serialized = Serialize(dataToSave, ProgressivePricingOfferModel);\n----\n\n[WARNING]\n====\n`DeserializeKeysFrom` / `SerializeKeysTo` mutate module-global state in `cerialize`.\nWhichever call ran most recently wins. Two consequences:\n\n* Always set the casing *immediately before* the `Deserialize`/`Serialize` call, in\n  the same `map` — never in a constructor or `ngOnInit`.\n* Interleaved async work can, in principle, observe another feature's casing\n  setting. In practice the set-then-use-synchronously pattern avoids it. Preserve\n  that pattern.\n====\n\nWhere a payload key genuinely does not follow either convention, the model keeps the\nAPI's own spelling — for example `AirwallexCardholderStatus` uses\n`connected_account_status`, `kyc_status`, `cardholder_terms_accepted` verbatim, and\n`ProgressivePricingOfferDTO`'s sibling `IAdvanceOfferRequest` uses\n`advance_offer_request_key`, `weekly_fee_percent`, and so on. That is intentional:\nthose interfaces describe the wire format, not the domain.\n\nA small helper, `shared/utils/to-lowercase-underscore.util.ts`, converts display\nlabels to `snake_case` column names for report queries — unrelated to `cerialize`\nbut easy to confuse with it.\n\n== Three model styles\n\nThe codebase has accreted three generations of model. All three are live; match the\nstyle of the file you are editing, and prefer style 3 for new code.\n\n=== 1. Decorated `cerialize` classes\n\nThe dominant style in `suppliers/model/`. Serialisation is declared with\ndecorators, and derived business predicates live as getters.\n\n[source,typescript]\n----\nexport class SupplierDetailsModel {\n  @autoserialize public supplierKey: string;\n  @autoserialize public companyDBA: string;\n  @autoserializeAs(SupplierStatusModel) public status?: SupplierStatusModel;\n  @autoserializeIndexable(SupplierMarketplaceListModel)\n  public marketplaces?: SupplierMarketplaceListModel[];\n\n  constructor(dto?: ISupplierDetailsDTO) { Object.assign(this, dto); }\n\n  get isTarget(): boolean { /* any marketplace is the Target marketplace */ }\n  get isAmazonVendor(): boolean { /* Amazon Vendor Central or Express */ }\n  get isAmazonSeller() { /* count of Amazon Seller Central links */ }\n}\n----\n\nDecorators in use:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Decorator | Meaning\n\n| `@autoserialize`\n| Primitive or plain object; participates in both directions.\n\n| `@autoserializeAs(Type)`\n| Nested model instance.\n\n| `@autoserializeIndexable(Type)`\n| Array/indexable collection of a model type.\n\n| `@inheritSerialization(Base)`\n| Class-level; inherits the base class's serialisation metadata (used by\n  `supplier-marketplace.model.ts`).\n|===\n\nNote the *paired DTO interface* convention: `ISupplierDetailsDTO` mirrors the\nclass's fields as all-optional, and the constructor takes it and `Object.assign`s.\nThat gives callers a cheap way to build a partial model without the decorators\ngetting in the way — which is exactly what the `UPDATE_MODEL_PART_SUCCESS` reducer\ndoes:\n\n[source,typescript]\n----\nconst updatedDetailsModel = new SupplierDetailsModel(supplierModel.supplierDetailsModel);\nObject.assign(updatedDetailsModel, action.payload.newValues);\n----\n\n[CAUTION]\n====\nBusiness getters on models (`isTarget`, `isAmazonSeller`, `isEFS`) are *lost* when\nthe object is spread. `{ ...supplierDetailsModel }` produces a plain object with no\ngetters. Several reducers and effects spread these models — if a downstream\nconsumer reads `isAmazonSeller` on a spread copy it silently gets `undefined`.\nReconstruct with `new SupplierDetailsModel(...)` when the getters matter.\n\n`isEFS` on `SupplierDetailsModel` unconditionally returns `true`. It is a stub, not\na real predicate.\n====\n\n=== 2. Plain classes with a DTO constructor\n\nUsed across `reports/`, `marketplaces/` and `processes/`. No decorators; the\nconstructor maps an incoming DTO.\n\n[source,typescript]\n----\nexport class ExternalReport implements IExternalReport {\n  constructor(dto: IExternalReportDTO) { /* explicit field mapping */ }\n}\n----\n\nServices then do `response.map(item => new ExternalReport(item))`. Slightly more\nverbose than decorators but with no global-state hazard, and easy to read.\n\n=== 3. Interfaces plus pure mapper functions\n\nThe newest style, best exemplified by\n`{source-root}/suppliers/model/card.model.ts`. Types describe the wire format and\nthe domain separately; free functions map and derive.\n\n[source,typescript]\n----\nexport type CardProvider = 'marqeta' | 'airwallex';\n\nexport interface CardBase { provider: CardProvider; cardId: string; status: string; lastFour?: string; }\nexport interface MarqetaCard  extends CardBase { provider: 'marqeta';  /* … */ }\nexport interface AirwallexCard extends CardBase { provider: 'airwallex'; formFactor: 'VIRTUAL' | 'PHYSICAL'; /* … */ }\nexport type SupplierCard = MarqetaCard | AirwallexCard;   // discriminated union\n\nexport function mapMarqetaCard(raw: any): MarqetaCard { /* … */ }\nexport function mapSupplierCard(raw: any): SupplierCard {\n  return raw.provider === 'airwallex' ? mapAirwallexCard(raw) : mapMarqetaCard(raw);\n}\n\nexport function isCardBlocked(card: SupplierCard): boolean {\n  return card.provider === 'marqeta' ? card.status === 'SUSPENDED' : card.status === 'INACTIVE';\n}\n\nexport function deriveAirwallexState(status): AirwallexOnboardingState { /* state machine */ }\n----\n\nThis style is preferred for new work because:\n\n* Provider-specific status vocabularies are normalised in one place\n  (`STATUS_MAP`, `normalizeCardStatus`, `normalizeCardId`).\n* The predicates are pure and trivially unit-testable (`card.model.spec.ts`).\n* A discriminated union makes the compiler enforce provider handling.\n\nSee xref:domains:cards.adoc[card programmes] for the business meaning of this\nparticular model.\n\n== Model, DTO, interface — which to write\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Kind | Use for\n\n| `interface I…DTO` / `…Dto`\n| The exact wire shape, with the backend's own key spelling. No behaviour.\n\n| `interface`\n| The domain shape consumed by components. Keys in `camelCase`.\n\n| `class …Model`\n| When you need `cerialize` decorators (because you serialise back), a\n  constructor-from-DTO, or derived getters.\n\n| `type` union\n| When a payload is polymorphic and a discriminant field exists.\n\n| `enum`\n| Closed vocabularies. Placed in `<module>/enums/` or `shared/enums/`. Examples:\n  `USER_PERMISSION`, `FEATURE_FLAGS`, `ExternalReports`, `UwStatus`,\n  `RoutingNumberValidationStatus`, `CrmItemStatus`, `PaymentConfigStatus`,\n  `MESSAGE_TYPE`, `CalculatorInputFields`, `OfferStatuses`.\n|===\n\nOne concept per file, named `<concept>.model.ts`, in the owning module's `model/`\nfolder. There are ~80 model files.\n\n== Polymorphic deserialisation\n\n`CrmService.listActions` demonstrates the app's one hand-rolled polymorphic mapper:\na single activity feed returns mixed types and is dispatched on `item.type`.\n\n[source,typescript]\n----\nswitch (item.type) {\n  case 'note':            return Deserialize(item, NoteModel);\n  case 'task':            return Deserialize(item, TaskModel);\n  case 'event':           return Deserialize(item, EventModel);\n  case 'file':            return Deserialize(item, FileModel);\n  case 'email':\n  case 'email_supplier':  return Deserialize(item, EmailModel);\n  case 'batch':           return Deserialize(item, EmailModel);\n  default:                return Deserialize(item, BaseCrmModel);\n}\n----\n\n`BaseCrmModel` is the fallback, so an unknown activity type degrades to a generic\ntimeline entry rather than throwing. Adding a CRM item type means touching this\nswitch, the `CrmStateModel` streams and the `CrmItem` union type. See\nxref:domains:crm.adoc[CRM].\n\n== Response envelopes\n\nBackends are not consistent. Know the four shapes you will meet:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Envelope | Where\n\n| Bare array/object\n| Internal API lookups (`countries`, `timezones`, `xactTypes`).\n\n| `{ results: T[], total: number }`\n| Internal API list endpoints (suppliers via `logins`, payment batches,\n  `pending_review`).\n\n| `{ data: T, meta: {} }` / `{ data: T[], meta: {} }`\n| Actions Tools, event rules, global settings. Typed as `Response<T>` /\n  `ListResponse<T>` in `manage/event-rules/events.service.ts` and unwrapped by\n  `GlobalSettingsService.castEntity`.\n\n| Elasticsearch hit envelope\n| `search3` on the suppliers endpoint and the event-logger changelog —\n  `{ hits: { hits: [...] } }` with highlight fragments, unwrapped by\n  `SearchDataModel` and `ElasticHighlightsService`.\n|===\n\nServices are responsible for unwrapping the envelope so that components and\nreducers only ever see models.\n\n== Aggregate models\n\nTwo classes are *aggregates* rather than payload mirrors — they exist only in the\nstore and are assembled from several calls:\n\n`SupplierModel`::\nHolds `supplierDetailsModel`, `currentFundingParticularsModel`, `marketplaces`,\n`marketplaceFundingModels`, `marketplaceOptInPeriodModels`,\n`marketplaceMinFeeOverridesModels`, `paymentConfigsModels`, `balance`,\n`kycDocumentsStatuses`, `complianceAmazonAccessStatus`, `efsCardRequested`. The\nmaps are keyed by `mpSupKey` / `paymentConfigKey`. Assembled by\n`SupplierEffects.getSupplierDetails` and friends.\n\n`SupplierMarketplaceModel`::\nThe same idea one level down: the marketplace link plus its funding, min-fee\noverrides and opt-in periods.\n\nNeither has `cerialize` decorators, because neither is ever sent to a backend.\n\n== Validators as domain logic\n\nA few validators encode business rules and are worth knowing about because they\nlive outside the models:\n\n* `suppliers/supplier/supplier-payment/routing-num.validator.ts` — a\n  `ValidatorFn` factory keyed on the routing-number verification status.\n* `suppliers/enums/routing-number-validation-status.enum.ts` +\n  `routing-number-validation-status.config.model.ts` — status → message/severity\n  mapping (`success` / `danger` / `warning`), including\n  `INSUFFICIENT_FOR_NATIONAL_PAYMENT` and\n  `INSUFFICIENT_FOR_INTERNATIONAL_PAYMENT`.\n* `manage/event-rules/rules-add-edit/action-form/send-to-event-bridge/validators/event-bridge-action-name.validator.ts`.\n* `manage/event-rules/event-rule.models.ts` — the `Filter` class, which parses and\n  renders filter expressions (see xref:platform:events.adoc[events]).\n"},{"component":"internal-admin","module":"architecture","path":"architecture:ui-composition.adoc","title":"UI composition and layout","description":"The application shell, the panels that surround the router outlet, the four table generations, and the shared form and dialog machinery.","url":"https://docx.payability.com/internal-admin/architecture/ui-composition.html","navOrder":13,"source":"= UI composition and layout\n:description: The application shell, the panels that surround the router outlet, the four table generations, and the shared form and dialog machinery.\n\n== The shell\n\nTwo nested components form the frame. `AppComponent` provides the theme classes and\nthe outer outlet; `MainLayoutComponent` provides the page structure.\n\n[source,html]\n----\n<!-- app.component.html -->\n<div class=\"preloaderbar active\" appPreloader><span class=\"bar\"></span></div>\n<div id=\"app-main\" class=\"app-main full-height\" [ngClass]=\"{ … }\">\n  <router-outlet></router-outlet>\n</div>\n----\n\n[source,html]\n----\n<!-- shared/layout/main-layout.html -->\n<app-stage-info></app-stage-info>\n<app-layout-sidenav></app-layout-sidenav>\n\n<section id=\"page-container\" class=\"app-page-container\">\n  <div class=\"app-content-wrapper\">\n    <div class=\"app-content {{ customClasses }}\">\n      <div class=\"full-height\"><router-outlet></router-outlet></div>\n    </div>\n    <a class=\"mat-fab mat-accent d-lg-none\" appToggleOffcanvasNav>…</a>\n  </div>\n</section>\n\n<app-search-overlay></app-search-overlay>\n----\n\nConsequences worth knowing:\n\n* `/login`, `/logout` and `/reset-password` are declared outside\n  `MainLayoutComponent`, so they render into the *outer* outlet with no sidebar,\n  stage banner or search overlay.\n* The search overlay is a singleton in the shell, opened by the\n  `appOpenSearchOverlay` directive from the sidebar.\n* `app-stage-info` shows which environment/back-end build you are pointed at — see\n  xref:platform:observability.adoc#versions[versions].\n\n=== Theme classes\n\n`AppComponent` binds a dozen classes on `#app-main` from two sources:\n\n* `ThemeConfig` (from `themeConfigData(env.envName)` in `{source-root}/config.ts`) —\n  `fixed-header`, `nav-collapsed`, `nav-behind`, `layout-boxed`, `theme-gray`,\n  `theme-dark`, `sidebar-sm`, `sidebar-lg`.\n* The `layout` store slice — `crm-fixed`, `quickview-open`, `supplier-info-open`,\n  `supplier-info-close`.\n\n`ThemeConfig` also carries `colorOption` (a two-digit code driving the sidebar\npalette; `11` on prod, `12` elsewhere), `sidebarWidth`, `productLink`, and\n`specialOccasion` from `shared/utils/functions/check-for-special-occasion.ts` —\nseasonal theming.\n\nAdditionally `AppComponent` toggles a `restricted-access` class on `document.body`\nfor `REST_UW_ACCESS` users, which SCSS uses to suppress UI wholesale.\n\n=== Global stylesheets\n\nEight SCSS entry points are listed in `angular.json`, loaded in this order:\n`styles.scss`, `bootstrap.scss`, `font-awesome.scss`, `layout.scss`, `loader.scss`,\n`material2-theme.scss`, `theme.scss`, `ui.scss`, then the two icon fonts.\n`stylePreprocessorOptions.includePaths` adds `src/assets`, so partials there can be\nimported by bare name.\n\nComponent SCSS should follow *ABEM without prefixes* — camelCase blocks and\nelements with separately-applied modifiers:\n\n[source,html]\n----\n<section class=\"uwCard__subtitle -active -selected -primary\">…</section>\n----\n\nSee xref:ops:conventions.adoc[conventions] for the full rule and its rationale.\n\n== The panels around the outlet\n\nThree regions surround the routed content and are controlled by the `layout` slice\nrather than by routing:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Region | Behaviour\n\n| Sidebar (`shared/sidenav/`)\n| Brand, menu, front-end and back-end version labels, logged-in user menu, help\n  link. Collapsible via `toggleCollapsedNav()`; auto-closes on mobile route change\n  via `appAutoCloseMobileNav`. The menu tree itself is\n  `sidenav-menu.component.html` — hand-maintained, permission- and flag-gated.\n\n| CRM panel (`crm-tools/crm-sidebar/`)\n| Four states: `HIDDEN`, `OPEN`, `CLOSED`, `FIXED` (`CrmStates`). Driven by\n  `CRM_PANEL_OPEN` / `_CLOSE` / `_FIX` / `_HIDE` actions and by the route-pattern\n  defaults.\n\n| Supplier info panel\n| Three states: `HIDDEN`, `OPEN`, `CLOSED` (`SupplierInfoStates`). Same mechanism.\n|===\n\n=== Route-pattern layout defaults\n\n`shared/layout/store/layout.reducers.ts` holds a `userLayoutConfig` keyed by three\nURL regexes:\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Pattern | Regex | Defaults\n\n| `supplierList`\n| `^/suppliers/list(.*)$`\n| CRM hidden, supplier info hidden, search visible, actions hidden.\n\n| `supplierGeneral`\n| `^/suppliers/(.*)/general(.*)$`\n| CRM **fixed**, supplier info **open**, search visible, actions **visible**.\n\n| `supplierDefault`\n| `^/suppliers/(.*)$`\n| CRM closed, supplier info open, search visible, actions visible.\n|===\n\n`LayoutUpdate(urlAfterRedirects)` — dispatched from `AppComponent` on\n`NavigationEnd` — selects the first matching pattern and applies its config, while\nalso remembering per-pattern user overrides. So \"the CRM panel is pinned on the\nsupplier General tab but merely available elsewhere\" is a *configuration* fact, not\ncomponent code.\n\n`LayoutService` supplies `customClasses$` for per-page body classes;\n`FabHolderNavComponent` hosts the floating action buttons.\n\n[[tables]]\n== Table generations\n\nFour table implementations coexist under `shared/`. Knowing which is which saves a\nlot of time.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Folder | Status and use\n\n| `pyb-ag-grid/`\n| **Current.** The wrapper module to use for new grids. Wraps AG Grid\n  {aggrid-version} with the project's defaults.\n\n| `data-table-ag-grid/`\n| AG Grid-based table with header-template rendering\n  (`data-table-ag-grid-header-template-renderer.service.ts`) and summed-value\n  headers (`services/headers-with-summed-values-setting.service.ts`). Widely used;\n  effectively the previous generation.\n\n| `data-table-grid/`\n| Includes `ag-greed-data-reshaper.service.ts` (sic) which reshapes API responses\n  into grid rows. Transitional.\n\n| `data-table/`\n| The oldest, Material-table-based implementation. Its `data-table.common.ts`\n  contributes `DataTableQueryParams`, which is still the shared query-parameter\n  contract used by services such as `BalanceApprovalService`.\n\n| `simple-table/`, `data-grid/`\n| Lightweight non-AG-Grid tables for small fixed lists.\n\n| `cell-components/`\n| Reusable AG Grid cell renderers shared across grids.\n\n| `pyb-pagination/`\n| Pagination control used with the non-AG-Grid tables.\n|===\n\n[TIP]\n====\nFor a new grid use `pyb-ag-grid`. Before changing a shared cell renderer, grep for\nits usages — the same renderers are consumed by grids in all four generations.\n====\n\nAG Grid **enterprise** features are in use, and the licence key is set in\n`main.ts` with an expired `ExpiryDate`. Expect a console warning; it does not\ndisable functionality.\n\n== Dialogs\n\n`shared/dialog/` provides `DialogModule` + `DialogService`, with `dialog.config.ts`\nholding shared `MatDialogConfig` defaults. Purpose-built dialogs sit alongside:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Component | Use\n\n| `confirm-dialog/`\n| Generic yes/no confirmation.\n\n| `confirm-delete/`\n| Delete confirmation with the item name.\n\n| `information-dialog/`\n| Informational, single-button.\n\n| `reason-dialog/`\n| Confirmation that *requires* a free-text reason — used by supplier lifecycle\n  actions where the reason is persisted.\n\n| `summary-dialog/`\n| Shows a computed summary before committing an operation.\n\n| `manage/permission-error-pop-up/`\n| The shared \"access denied\" dialog.\n|===\n\nDomain modals live with their feature, not in `shared/` — the ~20 folders under\n`suppliers/supplier/` such as `add-receivable/`, `add-chargeback/`,\n`cancel-supplier/`, `transaction-modal/` are each a dialog plus its service.\n\nNotifications are toasts (`ngx-toastr`), never dialogs, and go through\n`ErrorVisualizationService` for errors. See\nxref:platform:error-handling.adoc[error handling].\n\n== Forms\n\nAngular reactive forms throughout. Shared form building blocks:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Component/service | Purpose\n\n| `forms/dynamic-form-control.service.ts`\n| Builds form controls from a configuration object — used where the field set is\n  data-driven (report configuration, onboarding templates).\n\n| `card-formgroup/`\n| The recurring \"editable card\" pattern: a titled panel with edit/save/cancel that\n  submits one `formGroupKey`. `card-formgroup-message.service.ts` carries\n  save/error feedback. This is the UI half of\n  xref:domains:suppliers.adoc#partial-editing[partial editing].\n\n| `inline-edit/`\n| Single-field in-place editing.\n\n| `input-file/`\n| File selection for uploads.\n\n| `radiobuttons/`, `checkbox-filters/`, `chip/`, `time-select-picker/`\n| Input primitives.\n\n| `editor/`\n| TinyMCE wrapper (`EditorModule`) for email bodies and rich notes.\n\n| `text-value/`, `title-with-icon/`, `no-data/`, `spinner-button/`\n| Display primitives — read-only value pairs, section headers, empty states, and a\n  button with an inline progress state.\n\n| `drag-and-drop-list/`\n| Ordering UI (onboarding steps, report columns).\n\n| `@ultimate/ngxerrors`\n| Declarative validation-message display; legacy, used in older forms.\n|===\n\n== Filtering and search\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Area | Detail\n\n| `filters/`, `checkbox-filters/`\n| Reusable filter controls bound to grid query parameters.\n\n| `filtering-panel/`\n| A composite panel with `services/filtering-panel-service/filtering-panel.service.ts`\n  coordinating multiple filter controls.\n\n| `search-overlay/`\n| The global search modal, opened by the `appOpenSearchOverlay` directive.\n  `search-engine-service-3-0/search-engine.service.ts` calls the internal API's\n  `search3` parameter (with a legacy `search2` path still in the file) and\n  `elastic-highlights.service.ts` renders match fragments.\n  `search-overlay-single-result/` renders one hit.\n|===\n\n`SearchEngineService.pybEncode` percent-escapes `?`, `=`, `&` and `+` as\n`~F`, `~3D`, `~26`, `~2B` before sending the phrase — a backend-specific encoding.\nDo not URL-encode instead; the backend expects this scheme.\n\nThe `layout` slice's `choosenSearch` (`NEW_SEARCH` / `OLD_SEARCH`) still exists with\n`CHOOSE_NEW_SEARCH` / `CHOOSE_OLD_SEARCH` actions; `NEW_SEARCH` is the default and\n`SearchEngineService.search()` has its feature check short-circuited to `if (1 === 1)`.\n\n== Directives\n\n18 directives, mostly layout and behaviour glue:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Directive | Effect\n\n| `appAutoCloseMobileNav`\n| Closes the sidebar on navigation when `AutoCloseMobileNav` is set.\n\n| `appToggleOffcanvasNav`\n| The mobile menu FAB.\n\n| `appAccordionNav`, `appAppendSubmenuIcon`, `appHighlightActiveItems`\n| Sidebar menu behaviour — accordion expansion, chevron injection, active-route\n  highlighting.\n\n| `appOpenSearchOverlay`\n| Opens the global search overlay.\n\n| `appEnabledByFeatureFlag`\n| Structural directive wrapping `NgIf` with a feature-flag check. See\n  xref:platform:feature-flags.adoc[feature flags].\n\n| `appPreloader`\n| Binds the top progress bar to `PendingInterceptorService`.\n\n| `disabled-reason.directive.ts`\n| Shows *why* a control is disabled — the tooltip pattern used across the supplier\n  workspace where a field is read-only due to status or permission.\n|===\n\n== Component base class\n\n`shared/component-subscriber/subscriber.component.ts` provides `SubscriberComponent`,\nwhich collects `this.subscriptions` and unsubscribes in `ngOnDestroy`.\n`AppComponent`, `MainLayoutComponent`, `ResizeIframeService` and many feature\ncomponents extend it.\n\n[TIP]\n====\nExtend `SubscriberComponent` and push into `this.subscriptions` rather than\nhand-rolling `takeUntil` — it is the established pattern and reviewers expect it.\n====\n\n== Animations\n\n`shared/animations/` holds the shared Angular animation triggers (panel slide,\nexpand/collapse) used by the CRM panel and expandable cards.\n`BrowserAnimationsModule` is imported in `AppModule`.\n"},{"component":"internal-admin","module":"architecture","path":"architecture:integration-landscape.adoc","title":"Integration landscape","description":"Every backend service and third party the Internal Admin talks to, what it is used for, and how it is reached.","url":"https://docx.payability.com/internal-admin/architecture/integration-landscape.html","navOrder":14,"source":"= Integration landscape\n:description: Every backend service and third party the Internal Admin talks to, what it is used for, and how it is reached.\n\n== The map\n\nThere is no API gateway. The application holds ~15 base URLs and each service\nwrapper reads the one it needs from `AppConfigService`.\n\n[source]\n----\n                          ┌────────────────────────────────┐\n                          │        Internal Admin          │\n                          └──┬──────┬───────┬──────┬───────┘\n        X-Session-Token ─────┘      │       │      │\n                                    │       │      │\n  ┌─────────────────────────────────▼──┐    │      │\n  │ internalApiUrl   database_v2/internal   │      │\n  │  suppliers, marketplaces links, payment │      │\n  │  configs, deposit accounts, fees,       │      │\n  │  reserves, reports, logins, perms,      │      │\n  │  lookups, advances, UCC, pending_review,│      │\n  │  paymentBatches, search2/search3        │      │\n  └─────────────────────────────────────────┘      │\n  ┌───────────────────────┐  ┌─────────────────────▼──────┐\n  │ cliApiUrl             │  │ actionsToolsUrl            │\n  │  database_v2/cli      │  │  CRM activity + settings   │\n  │  scripted/bulk ops    │  │  notes, tasks, files,       │\n  └───────────────────────┘  │  filters, global settings   │\n                             └────────────────────────────┘\n  ┌───────────────────────┐  ┌────────────────────────────┐\n  │ eventLoggerUrl        │  │ eventRulesOldUrl           │\n  │  event stream,        │  │  event rules v1 +           │\n  │  es-changelog, track  │  │  Elasticsearch em_events    │\n  └───────────────────────┘  └────────────────────────────┘\n  ┌───────────────────────┐  ┌────────────────────────────┐\n  │ messagingUrl          │  │ authToolsApiUrl            │\n  │  outbound email/SMS   │  │  supplier tokens, SMS nums  │\n  └───────────────────────┘  └────────────────────────────┘\n  ┌───────────────────────┐  ┌────────────────────────────┐\n  │ workflowApiUrl (+GCI) │  │ automaticMarketplaceApiUrl │\n  │  b2btaskmanager       │  │  automatic UW tool,         │\n  │  jobs + SSE stream    │  │  real-time validation       │\n  └───────────────────────┘  └────────────────────────────┘\n  ┌───────────────────────┐  ┌────────────────────────────┐\n  │ marketplaceApiUrl     │  │ supWidgetsApiUrl           │\n  │  marketplace service  │  │  onboarding widgets         │\n  └───────────────────────┘  └────────────────────────────┘\n  ┌──────────────────────────────────────────────────────┐\n  │ Four document/file backends                          │\n  │  b2bDocumentsApiUrl · pybDocumentsApiUrl ·           │\n  │  documentsApiUrl · s3FilesProviderApi                │\n  └──────────────────────────────────────────────────────┘\n  ┌───────────────────────┐  ┌────────────────────────────┐\n  │ evaApiUrl (iframes,   │  │ supplierUrl /               │\n  │  postMessage, cookie) │  │ supplierReferralUrl         │\n  └───────────────────────┘  └────────────────────────────┘\n\n  Third parties reached directly from the browser:\n    Google Identity (SSO) · Segment analytics.js · Google Analytics ·\n    TinyMCE assets (self-hosted) · Sentry DSN configured but unused in code\n----\n\n== Payability backends\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Base URL key | Service | Primary consumers\n\n| `internalApiUrl`\n| The main internal API (`database_v2/internal`, `bin/internal` on prod). The\n  system of record for suppliers, money and configuration.\n| ~35 services — `SupplierService`, `PaymentConfigService`,\n  `DepositAccountService`, `SupplierFeesService`, `SupplierAdvancesService`,\n  `SupplierMarketplacesService`, `UsersService`, `LookupRepository`,\n  `ReportsRepositoryService`, `ExternalReportsService`,\n  `PaymentBatchRepositoryService`, `BalanceApprovalService`,\n  `PendingReviewService`, `UccService`, `SearchEngineService`, `VersionService`,\n  `CardManagementService`, `FeeSettingsService`, `WinbackService`,\n  `SupplierComplianceActionService`, `SupplierEmailPreferencesService`,\n  `LongTermContractsService`, `PybDocumentsService`, and others.\n\n| `cliApiUrl`\n| The CLI service (`database_v2/cli`) — scripted and bulk operations.\n| `CliService`, `CliActionService`, `BatchImportService`, `SupplierService`.\n\n| `actionsToolsUrl`\n| Actions Tools — CRM activity storage and the global key/value settings store.\n| `CrmService`, `CommentsService`, `TasksService`, `TasksFiltersService`,\n  `GlobalSettingsService`, `BatchImportService`,\n  `WaitingSuppliersReportComponent`.\n\n| `eventLoggerUrl`\n| Event logger v2 — the domain event stream, the Elasticsearch changelog\n  (`es-changelog`) and the analytics `track` endpoint.\n| `CrmService` (event stream), `MonitorService` (tracking).\n\n| `eventRulesOldUrl`\n| Event logger v1 — event-rule CRUD (`event_rules/`), rule history\n  (`event_rules/history/`) and a raw Elasticsearch endpoint (`es`, index\n  `em_events`).\n| `EventsService` (`manage/event-rules/`).\n\n| `messagingUrl`\n| Messaging — outbound email and SMS.\n| `EmailsService`, `CrmService`, `SmsNumbersProviderService`.\n\n| `authToolsApiUrl`\n| Auth tools — supplier auth-token issuance and SMS number provisioning.\n| `SupplierAuthTokenService`, `SmsNumbersProviderService`.\n\n| `workflowApiUrl` + `workflowGciId`\n| B2B task manager — long-running jobs (earnings/remittance loads) plus a\n  server-sent-events progress stream (`tasksMonitoring`) and `getLastExecution`.\n  `workflowGciId` is a build-time correlation id generated by\n  `deployment/generate_gci.py` (hardcoded to `1` on production).\n| `B2bWorkflowService`, `BatchImportService`.\n\n| `marketplaceApiUrl`\n| Marketplace service — supplier-marketplace data.\n| `SupplierMarketplacesService`.\n\n| `automaticMarketplaceApiUrl`\n| Automatic underwriting tool web API.\n| `SupplierUnderwritingService`, `RealTimeValidatorService`.\n\n| `supWidgetsApiUrl`\n| Supplier widget endpoints used during onboarding.\n| `SupplierOnboardingService`.\n\n| `b2bDocumentsApiUrl`\n| B2B document service.\n| `B2bDocumentService`, `BatchImportService`.\n\n| `pybDocumentsApiUrl`\n| PYB documents service — behind the `PYB_DOCUMENTS` feature flag.\n| `PybDocumentsService`.\n\n| `documentsApiUrl`\n| Documents Lambda — deposit-account documents.\n| `DepositAccountService`.\n\n| `s3FilesProviderApi`\n| S3 file provider.\n| Configured in `IEnvironment`; used by file-download paths.\n\n| `supplierUrl`\n| The supplier self-service site — target for \"log in as supplier\".\n| `LoginAsSupplierService`.\n\n| `supplierReferralUrl`\n| Referral link base.\n| `SupplierReferralService`.\n\n| `evaApiUrl`\n| EVA micro-front-end origin. Not an HTTP API from this app's perspective — it is\n  an iframe `src` plus a `postMessage` channel plus a shared-domain cookie.\n| `EvaService`, `EvaCommunicationService`, `CommunicationService`.\n|===\n\nEndpoint-level detail is in\nxref:reference:services-and-endpoints.adoc[services and endpoints]; the\nenvironment values per stage are in\nxref:reference:environment-variables.adoc[environment configuration keys].\n\n== Authentication across integrations\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Mechanism | Where\n\n| `X-Session-Token: <jwt>`\n| Added to *every* `HttpClient` request by `AuthInterceptor`, regardless of which\n  host it goes to. All Payability backends accept it.\n\n| `Authorization: Basic <base64>`\n| Only the login call (`internalApiUrl + auth/login`), constructed inline in\n  `AuthEffects`.\n\n| `Authorization: <jwt>` (no scheme)\n| `CrmService` sets the raw token as `Authorization` for Actions Tools, in\n  addition to the interceptor's header.\n\n| `Authorization: Token <literal>`\n| `MonitorService`'s event-logger call — a hardcoded credential. Known debt.\n\n| `X-InstanceId` / `X-EventLogger-InstanceId`\n| `env.instanceId` (8001) for event rules, global settings and CRM;\n  `MonitorService` hardcodes `8003`.\n\n| `X-Actions-TargetUUID` / `X-EventLogger-TargetUUID`\n| The CRM \"what is this about\" key — kept in sync by a store subscription inside\n  `CrmService`.\n\n| EVA cookie\n| `EvaService` writes the token into a cookie on `environment.domain` so the\n  iframe can read it; on `localhost` it appends `?auth=<token>` to the iframe URL\n  instead.\n\n| Google ID token\n| Exchanged for a Payability JWT via `LookupRepository.getAuthGoogleLogin`.\n|===\n\nSee xref:platform:authentication.adoc[authentication] for the flows.\n\n== Third parties in the browser\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Provider | Integration\n\n| Google Identity\n| `googleAuthClientId` in `IEnvironment` (the same client id in dev and prod\n  templates). The login component uses `gapi.auth2` typings; the credential is\n  posted to the internal API for exchange. Only specific localhost ports are\n  registered with Google — see xref:ops:local-development.adoc[local development].\n\n| Segment (`analytics.js`)\n| `MonitorService` inlines the standard Segment snippet, loading\n  `cdn.segment.com/analytics.js/v1/<frontendSegmentIOCode>analytics.min.js`. Used\n  for `identify` and `track`. `window.onerror` in `main.ts` also tracks\n  `WINDOW ERROR`.\n\n| Google Analytics\n| `MonitorService` inlines the classic `analytics.js` GA snippet under the global\n  name `fga`.\n\n| Elasticsearch\n| The `elasticsearch` npm client runs *in the browser* against\n  `eventRulesOldUrl + 'es'`, index `em_events`, passing `X-Session-Token`\n  explicitly. Only `manage/event-rules/events.service.ts` does this.\n\n| TinyMCE\n| Self-hosted: the build copies `node_modules/tinymce` to `/tinymce/`.\n\n| Sentry\n| `sentryUrl` is present in `IEnvironment` and identical across templates, but no\n  Sentry SDK is installed and no code reads the key. `AppErrorHandler` merely\n  advises \"Check sentry logs or run in dev mode.\" Treat Sentry as *not wired up*\n  from this application.\n\n| Plaid\n| Reached indirectly — bank-transaction data arrives through the internal API's\n  external-report endpoints (`ExternalReports.PLAID = 'PLAID-D'`) and the\n  supplier-payment Plaid information service, not via a Plaid browser SDK.\n\n| Marqeta / Airwallex / EFS\n| Card issuing. All reached through the internal API's card-management endpoints —\n  the app never calls the issuers directly. See xref:domains:cards.adoc[cards].\n|===\n\n== Real-time and cross-window channels\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Channel | Detail\n\n| Server-sent events\n| `SseService` uses `EventSourcePolyfill` from `ng-event-source` so that the\n  `X-Session-Token` header can be sent (native `EventSource` cannot). It reads the\n  token by subscribing to the `auth` slice and marshals messages back into the\n  Angular zone with `NgZone.run`. Consumed by `B2bWorkflowService.taskMonitoring()`.\n  See xref:platform:realtime.adoc[real-time streams].\n\n| `postMessage`\n| The EVA protocol: `CommunicationService` with a typed `MESSAGE_TYPE` enum,\n  origin checks against `environment.domain`, per-iframe `pluginIframeId`\n  addressing, and `document.domain` assignment. See\n  xref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\n| Browser extension\n| `messageCodes.installExtension` and the \"Payability Extension not Installed\"\n  message indicate a companion browser extension used by the login-as-supplier\n  and Amazon-login flows (`PybExtensionService`).\n|===\n\n== VPN gating\n\n`VpnTesterService`, started by `AppComponent` when the `CHECK_VPN` flag is on,\nprobes whether the operator is on the corporate network. It is a soft check that\nwarns rather than blocks; the authoritative network restriction is enforced\nupstream of the application.\n\n== Adding a new integration\n\n. Add the base URL to `IEnvironment` (`src/environments/environment.interface.ts`).\n. Add the corresponding `export` to *both* `deployment/setup_template-dev.cfg` and\n  `deployment/setup_template-production.cfg`.\n. Add the field to the generated template in `deployment/set-env.ts`.\n. Regenerate your local environment (`./deployment/setenv.sh exp`) — do not\n  hand-edit `src/environments/environment.ts`, it is overwritten.\n. Read it in the service via `AppConfigService`, never `import { environment }`.\n. Document it here and in\n  xref:reference:environment-variables.adoc[environment configuration keys].\n\nMissing any of steps 2–3 produces the classic failure: the key is `undefined` in\nCI/deployed builds while working locally. See\nxref:ops:environments.adoc[environments].\n"},{"component":"internal-admin","module":"architecture","path":"architecture:decisions.adoc","title":"Architectural decisions and constraints","description":"The standing decisions that shape this codebase, why they hold, and the known debt.","url":"https://docx.payability.com/internal-admin/architecture/decisions.html","navOrder":15,"source":"= Architectural decisions and constraints\n:description: The standing decisions that shape this codebase, why they hold, and the known debt.\n\nThis page records decisions that are *already made*. They are not up for\nre-litigation inside a feature task; if one needs revisiting, that is its own\nscoped piece of work with its own spec.\n\n== Standing decisions\n\n=== D1 — Versions are pinned; do not upgrade opportunistically\n\n*Decision.* Angular {angular-version}, TypeScript {typescript-version}, NgRx\n{ngrx-version}, RxJS {rxjs-version}, AG Grid {aggrid-version}, Node\n{node-version}, Karma/Jasmine as the test runner. No dependency bumps, no\nframework migration, no new state library, no swapping the test runner unless a\ntask explicitly asks for it.\n\n*Why.* The blockers compound:\n\n* NgRx 4 → modern NgRx is a rewrite of every action, reducer, effect and selector\n  in twelve slices.\n* `rxjs-compat` is load-bearing — legacy deep imports\n  (`rxjs/observable/forkJoin`, `rxjs/observable/ErrorObservable`) appear across\n  dozens of files.\n* Node {node-version} constrains every build tool, and modern tooling (including\n  Antora) will not run on it.\n* AG Grid 23 → current is several breaking majors and touches every grid.\n* `@angular/http` (`HttpModule`) is still imported in `AppModule`.\n* Application code imports Node's `util` (`isUndefined`, `isArray`, …), which\n  depends on the webpack 4 Node polyfill.\n\n*Consequence.* You will meet APIs that look outdated. Match them.\n\n=== D2 — Feature-first, fractal folder structure\n\n*Decision.* Organise by feature, recursively, rather than by file type. Reject\nAngular's \"as flat as possible\" guidance.\n\n*Why.* The repository's own conventions state it: a flat structure \"leads to\ndesigns that are very difficult to maintain\", and the folder tree should reflect the\nbusiness and UI structure. With ~420 components, a flat layout is unnavigable.\n\n*Consequence.* `model/`, `services/`, `store/`, `shared/` recur at several depths.\nPlacement is decided by *scope* — see xref:module-map.adoc[the module map].\n\n=== D3 — Selective state, not total state\n\n*Decision.* Only cross-screen state goes in the NgRx store. Feature-local state\nstays in the component. Do not add a slice for something one screen owns.\n\n*Why.* NgRx 4 boilerplate is expensive per slice, and the twelve existing slices\nalready carry the shared concerns (auth, current supplier, CRM target, layout,\nreference data, settings).\n\n*Consequence.* Most of the app is plain components calling services. Shared state\nalso lives in `BehaviorSubject` caches inside services (`UsersService`,\n`FeatureFlagsService`) and in the `TableEventsService` bus — check those before\nadding a slice.\n\n=== D4 — Everything is eagerly loaded\n\n*Decision.* One bundle; no `loadChildren` in the live route tables.\n\n*Why.* Internal tool, small and stable user population, no cold-start pressure on\nfirst paint from a marketing perspective. Lazy loading was never introduced, and\nroute guards (`FeatureFlagGuard` implements `CanLoad`) hint that it was considered.\n\n*Consequence.* The AOT build needs a raised heap\n(`--max_old_space_size` 8192 locally, 12288 in `deployment/build.sh`). The initial\nbundle is large. Do not add `loadChildren` to a single module in isolation — the\ngain is nil until the pattern is applied broadly, and it would change guard\nsemantics.\n\n=== D5 — Hash routing\n\n*Decision.* `useHash: true`.\n\n*Why.* Static hosting on S3 behind CloudFront without server-side rewrite rules for\ndeep links.\n\n*Consequence.* URL manipulation in code has to strip `/#/` — see\n`RouteAccessResolver.handleLogout()`. Changing this requires hosting changes and\ninvalidates every bookmark.\n\n=== D6 — Authorisation is advisory in the client, authoritative in the backend\n\n*Decision.* The front end hides and gates UI with `ngx-permissions`, but every\nprotected operation is enforced server-side.\n\n*Why.* A JavaScript bundle cannot keep a secret. Client-side gating is a usability\nand least-surprise measure.\n\n*Consequence.* Never treat a guard as a security control. A missing guard is a UX\nbug; a missing backend check is a security bug. See\nxref:platform:authorization.adoc[authorisation].\n\n=== D7 — Feature flags on everything new, by default\n\n*Decision.* New modules and routes are flag-gated. The repository README states it\nin capitals: flags \"SHOULD BE USED ON ALL MODULES BY DEFAULT\".\n\n*Why.* Trunk-based deployment to many environments from `{default-branch}`, with\nproduction released from `{release-branch}`. Flags decouple deploy from release.\n\n*Consequence.* Three flag layers (build, global settings, session) with a defined\nprecedence, plus a guard and a structural directive. See\nxref:platform:feature-flags.adoc[feature flags].\n\n=== D8 — Configuration is generated, not committed by hand\n\n*Decision.* `src/environments/environment.ts` is produced by\n`deployment/setenv.sh` → `deployment/set-env.ts` from shell variables in\n`setup_template-{dev,production}.cfg`. It is committed, but as generated output.\n\n*Why.* One template drives every environment; the build stamps in the git hash and\nbuild date; secrets and per-environment hosts stay out of application source.\n\n*Consequence.* Hand-editing `environment.ts` works locally and then silently fails\nin CI. Adding a config key means touching four files. See\nxref:ops:environments.adoc[environments].\n\n=== D9 — EVA is embedded, not merged\n\n*Decision.* Parts of the supplier workspace are rendered by a separate front end in\niframes, integrated over a typed `postMessage` protocol.\n\n*Why.* Independent deployability for the newer UI without rewriting Internal Admin.\n\n*Consequence.* A hard seam with explicit two-way state synchronisation, origin\nchecks, `document.domain` assignment, a shared-domain auth cookie, and iframe\nresizing driven by messages. The contract is `MESSAGE_TYPE`; changing it is a\ncross-repository change. See xref:platform:iframe-messaging.adoc[iframe messaging].\n\n=== D10 — Spec before code\n\n*Decision.* Non-trivial changes are specified in `.specs/*.md` before\nimplementation, and reviewed as a spec before any code is written. Four agent\nphases: planner → plan reviewer → implementer → implementation reviewer.\n\n*Why.* Stated in `CLAUDE.md`/`AGENTS.md`: small verifiable steps, a cheap\ngeneration→verification loop, and no \"vibe\" diffs in a codebase this large and this\nclose to money movement.\n\n*Consequence.* If reality contradicts the spec mid-implementation, stop and update\nthe spec. See xref:ops:spec-driven-workflow.adoc[the spec-driven workflow].\n\n=== D11 — Abstract service + mock sibling as the test seam\n\n*Decision.* Services that specs need to stub get an abstract base class bound by\ntoken in `AppModule`, and/or a `*-mock.service.ts` sibling.\n\n*Why.* Predates and substitutes for heavier DI/testing machinery; keeps specs from\nneeding `HttpTestingController` everywhere.\n\n*Consequence.* ~11 abstract bases and ~30 mock services. Mocks must never be\nreachable from production paths. See\nxref:architecture:http-and-services.adoc#abstractions[service abstractions].\n\n== Known debt\n\nRecorded so that nobody rediscovers it as a surprise. None of these are invitations\nto a drive-by fix inside an unrelated task.\n\n[cols=\"1,3,1\",options=\"header\"]\n|===\n| Area | Debt | Risk\n\n| Unfinished migrations\n| Four table generations (`data-table`, `data-table-grid`, `data-table-ag-grid`,\n  `pyb-ag-grid`); event rules v1 *and* v2; `supplier-underwriting` *and*\n  `supplier-underwriting-beta`; three document backends plus an S3 provider; old\n  and new search engines.\n| Medium — duplicated behaviour, unclear \"which one\" for newcomers.\n\n| Committed build residue\n| Stale `*.js` / `*.js.map` files sit beside their `.ts` sources throughout\n  `{source-root}`.\n| Low — but they pollute every grep and diff.\n\n| Immutability violations\n| `supplier.reducers.ts` mutates nested models (`supplierDetailsModel.hide = !…`,\n  `delete state.supplierModel.marketplaces`) inside otherwise-spread state.\n| Medium — blocks any move to `OnPush` and makes memoised selectors unreliable.\n\n| Getters lost on spread\n| Model getters (`isTarget`, `isAmazonSeller`) silently become `undefined` when the\n  model is spread into a plain object.\n| Medium — fails quietly.\n\n| Global serialisation state\n| `cerialize`'s `DeserializeKeysFrom`/`SerializeKeysTo` are module-global and set\n  per call site.\n| Medium — ordering-sensitive.\n\n| Hardcoded credentials and keys\n| `MonitorService` embeds `Authorization: Token <literal>` and instance id `8003`;\n  `main.ts` embeds an *expired* AG Grid licence key; `googleAuthClientId` is the\n  same string in dev and prod templates.\n| Medium.\n\n| Devtools in production\n| `StoreDevtoolsModule.instrument()` is unconditional, and\n  `FetchedRequestsInterceptor` records the last 50 requests (bodies included) into\n  state.\n| Medium — exposes internal request shapes in a production build.\n\n| Sentry configured but unused\n| `sentryUrl` exists in every environment; no SDK is installed and no code reads it,\n  yet `AppErrorHandler` tells users to check Sentry.\n| Low — misleading.\n\n| Route ordering hazard\n| In `/processes`, `:processKey/:paymentKey` and `:supKey` precede `approval`, which\n  is why Balance Approval is linked through the misspelled `/proceses` table.\n| Medium — brittle; a reorder breaks live links.\n\n| Dead code paths\n| `SearchEngineService.search()` guarded by `if (1 === 1)`;\n  `EnableStoreDebugging = false` requiring a source edit;\n  `SupplierDetailsModel.isEFS` always `true`; an unreachable `return state` after a\n  `return` in `supplier.reducers.ts`; `backgroundReports` effects file empty;\n  `CachingInterceptor` implemented but never registered.\n| Low individually; collectively they mislead readers.\n\n| Unmaintained E2E suite\n| Cypress {cypress-version} suite is explicitly marked *not maintained* in the\n  repository README, and `test:ci` is commented out of `buildspec.yml`.\n| High for regression safety.\n\n| `SupplierService` breadth\n| One service covers details, tokens, reports, balances, advances, health and\n  statements.\n| Medium — a change magnet.\n\n| `shared/` → `suppliers/` coupling\n| `EvaCommunicationService` and `LookupRepository` (in `shared/`) import supplier\n  models, inverting the intended dependency direction.\n| Low — contained, but do not extend.\n\n| Legacy dependencies\n| `@angular/http`, `ngx-resource`, `@ultimate/ngxerrors`, jQuery + slimscroll,\n  Bootstrap 4 beta, `core-js` 2, `rxjs-compat`, the unused webpack loader set.\n| Medium — each is an upgrade blocker.\n|===\n\n== When you think a decision should change\n\n. Write it up in `.specs/` as its own change, with the blast radius enumerated.\n. Get the plan reviewed before any code — that is what the plan-reviewer phase is\n  for.\n. Prefer strangling over rewriting: introduce the new pattern beside the old,\n  migrate call sites incrementally, and *finish the migration*. The debt table above\n  is mostly a record of migrations that were started and abandoned.\n"},{"component":"internal-admin","module":"domains","path":"domains:index.adoc","title":"Business domains","description":"Index of the business logic documented in this component, and where the rules actually live.","url":"https://docx.payability.com/internal-admin/domains/index.html","navOrder":16,"source":"= Business domains\n:description: Index of the business logic documented in this component, and where the rules actually live.\n\nThis module documents *what the application does and why*, domain by domain. The\narchitectural counterpart — how it is built — is in\nxref:architecture:overview.adoc[the architecture module].\n\n== Who owns which rule\n\nBefore reading any domain page, be clear about where a rule is enforced. The front\nend owns less than it appears to.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Owner | Rules\n\n| *Backend* (authoritative)\n| Whether money can move. Balance computation, fee charging, advance repayment and\n  recovery, remittance matching, batch settlement, permission enforcement,\n  statement generation, KYC decisioning, all persistence and all validation that\n  matters.\n\n| *Front end* (real logic, documented here)\n| Progressive-pricing arithmetic in the advance calculator (period totals, average\n  fee, net delivery, reserve, risk-rating defaults); event-rule filter expression\n  parsing and rendering; feature-flag layer precedence; route admission and\n  navigation gating; form validation and cross-field rules; card provider status\n  normalisation and Airwallex onboarding state derivation; CRM timeline\n  normalisation; report column/query construction; supplier partial-edit\n  orchestration.\n\n| *Front end* (presentation only)\n| Everything else — the app displays and submits; the backend decides.\n|===\n\n[IMPORTANT]\n====\nWhen a domain page says \"the app computes X\", that means the arithmetic runs in the\nbrowser and is submitted for the backend to accept or reject. It never means the\nbrowser is the system of record.\n====\n\n== Domain index\n\n=== Supplier — the core aggregate\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Scope\n\n| xref:suppliers.adoc[Suppliers]\n| The supplier entity and its workspace; the `supplier` store slice; the\n  part-by-part editing mechanism that all supplier tabs share.\n\n| xref:supplier-onboarding.adoc[Onboarding and KYC]\n| Onboarding templates, the services/product matrix, compliance actions and KYC\n  document verification, marketplace account access, 2FA and SMS numbers.\n\n| xref:supplier-underwriting.adoc[Underwriting]\n| Initial and daily underwriting, supplier information, documentation, background\n  reports, external report tabs, UCC filings and TransUnion, Amazon health rules,\n  real-time validation, Marqeta KYC compliance.\n\n| xref:advances.adoc[Advances and offers]\n| The offer lifecycle (request → underwrite → price → approve → re-offer),\n  advance lines, ML offer eligibility, and the progressive-pricing calculator.\n\n| xref:supplier-payments.adoc[Supplier payments and deposit accounts]\n| Outgoing payment configurations, incoming deposit accounts, Plaid bank\n  verification, routing-number validation, auto-payment, payment history.\n\n| xref:fees-and-reserves.adoc[Fees, reserves and contracts]\n| Funding particulars, purchase and payment fees, per-marketplace reserves and\n  minimum-fee overrides, fee settings defaults, long-term contracts.\n\n| xref:cards.adoc[Card programmes]\n| EFS, Marqeta and Airwallex — provider discovery, card issuing, status\n  normalisation, the Airwallex cardholder onboarding state machine.\n\n| xref:supplier-lifecycle.adoc[Lifecycle actions and transactions]\n| Hide, suspend, lock, cancel, write off, delete, win-back; and the global Actions\n  menu operations (earnings/remittance loads, chargebacks, recoveries,\n  receivables, write-offs, statement and payment adjustments, fund transfers).\n|===\n\n=== Around the supplier\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Page | Scope\n\n| xref:marketplaces.adoc[Marketplaces and gateways]\n| The marketplace catalogue, payment gateways, the supplier↔marketplace link and\n  marketplace statements.\n\n| xref:processes.adoc[Payment processes and batches]\n| Fund-transfer batch lifecycle, per-supplier payment details, batch downloads,\n  pending edits.\n\n| xref:balance-approval.adoc[Balance approval]\n| The treasury review queue, decisions and outcome reasons, review statistics.\n\n| xref:crm.adoc[CRM tools and tasks]\n| The activity timeline (notes, tasks, files, emails, events), task workflow, task\n  filters and counters, comments, pinned notes.\n\n| xref:emails.adoc[Emails and messaging]\n| The email log and preview, email settings and preferences, batch email, SMS.\n\n| xref:reports.adoc[Reports]\n| Configuration-driven internal reports, external reports, the waiting-suppliers\n  report, CSV export.\n\n| xref:manage.adoc[Manage]\n| Bulk imports and CLI actions, users and permission groups, global settings,\n  configs, email settings, fee settings, onboarding templates, contracts,\n  month-end close, the raw event stream.\n\n| xref:underwriting-tracker.adoc[Underwriting tracker]\n| The cross-supplier underwriting queue.\n\n| xref:admin.adoc[DEV Admin]\n| Runtime feature-flag switching (including for EVA) and QA tooling.\n|===\n\nCross-cutting mechanisms these domains all rely on — authentication, permissions,\nfeature flags, events, error handling — are in\nxref:platform:authentication.adoc[the platform module].\n\n== Reading a domain page\n\nEach page follows the same structure:\n\n. *What it is* — the business purpose in plain terms.\n. *Where it lives* — routes, components, services, store slice.\n. *How it works* — the flow, with the rules the front end owns called out.\n. *Business rules and constraints* — statuses, validations, permission and flag\n  gates.\n. *Gotchas* — the things that have caused bugs.\n\nEvery claim cites the implementing file under `{source-root}`. Where a citation and\nthe code disagree, the code is right and the page needs fixing.\n"},{"component":"internal-admin","module":"domains","path":"domains:suppliers.adoc","title":"Suppliers","description":"The central entity, its workspace, its store slice, and the partial-editing mechanism every supplier tab uses.","url":"https://docx.payability.com/internal-admin/domains/suppliers.html","navOrder":17,"source":"= Suppliers\n:description: The central entity, its workspace, its store slice, and the partial-editing mechanism every supplier tab uses.\n\n== What it is\n\nA *supplier* is a marketplace seller — Payability's customer. Everything else in\nthe application hangs off a `supplierKey` (a UUID). Roughly half of the codebase\n(~200 components) is the supplier module.\n\nThe supplier record is not one table but an *aggregate*: identity and business\ndetails, funding particulars, balance, marketplace links, payment configurations,\ncompliance/KYC statuses, contracts and card accounts, each from a different\nendpoint.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `{source-root}/suppliers/suppliers-routing.routes.ts` — `/suppliers/list` and\n  `/suppliers/:supplierKey/<tab>` with ~25 tab routes.\n\n| Browse\n| `suppliers/supplier-list/`\n\n| Workspace shell\n| `suppliers/supplier/supplier.component.*` plus\n  `suppliers/supplier/supplier-box-header/` (two generations — the current one and\n  `supplier-box-header-old/`).\n\n| Store\n| `suppliers/store/supplier/` — `supplier.actions.ts` (~70 action types),\n  `supplier.reducers.ts`, `supplier.effects.ts`.\n\n| Models\n| `suppliers/model/` — ~35 files. `SupplierModel` is the aggregate;\n  `SupplierDetailsModel` is the persisted record.\n\n| Services\n| `suppliers/services/` — ~35 services.\n|===\n\n== The supplier record\n\n`SupplierDetailsModel` (`suppliers/model/supplier.model.ts`) is the persisted\nrecord. The fields that carry business meaning:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Group | Fields\n\n| Identity\n| `supplierKey`, `legalName`, `companyDBA`, `taxId`, `businessTypeCode`,\n  `stateOfIncorporation`, `incorporatedInTheUS`, `dateEstablished`,\n  `businessWebsite`, `schema`.\n\n| Contact\n| `contactFirstName`, `contactLastName`, `contactEmail`, `phoneNumber`,\n  `instantMessenger` (typed `Skype`), `timezone`, `address`, `homeAddress`.\n\n| Owners\n| `owners: SupplierOwnerModel[]`, `ownerSSN`, `dateOfBirth`, `extOwnerName`. Extra\n  owners are managed on their own tab\n  (`suppliers/supplier/additional-business-owners/`).\n\n| Products enabled\n| `instantAccess`, `achAvailable`, `wirAvailable`, `ppmAvailable`,\n  `efsAvailable`, `pcardAvailable`, `loadNetEarnings`.\n\n| EFS\n| `efsRebatePercent`, `efsLastCardReqTs`.\n\n| Marqeta\n| `marqetaStatuses: { businessState, userState }`.\n\n| Funding\n| `fundFacilityLimit`, `oldestOptIn`, `depositAccountKey`.\n\n| Lifecycle\n| `status: SupplierStatusModel`, `hide`, `supplierLocked`, `cancelationStatus`,\n  `writeoff`, `pendApprovNeeded`, `modified`.\n\n| Onboarding\n| `onboardingStatus`, `onboardingTemplateCode`, `supplierSite`.\n\n| Marketplaces\n| `marketplaces: SupplierMarketplaceListModel[]`.\n\n| External CRM\n| `extDealId`, `extStageName`, `extStatus`, `extOwnerName` — the link to the\n  external sales CRM.\n\n| Email\n| `sendAutoEmail`.\n|===\n\n=== Derived predicates\n\nThree getters encode marketplace-based product rules:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Getter | Rule\n\n| `isTarget`\n| Any linked marketplace has `marketplaceKey === TARGET_MARKETPLACE_KEY`\n  (`0dcb4780-…`, defined in `supplier-onboarding.service.ts`).\n\n| `isAmazonVendor`\n| Any linked marketplace is named `Amazon Vendor Central` or\n  `Amazon Vendor Express` — matched on *display name*, not key.\n\n| `isAmazonSeller`\n| Returns the *count* of `Amazon Seller Central` links (truthy number, not a\n  boolean). `AppComponent` tests it with `> 0` when computing EVA menu conditions.\n\n| `isEFS`\n| Unconditionally `true`. A stub — do not rely on it.\n|===\n\n[CAUTION]\n====\n`isAmazonVendor` and `isAmazonSeller` match on marketplace *names*. A rename in the\nmarketplace catalogue silently changes behaviour. `isTarget` correctly matches on\nkey. Also remember these are getters: they vanish when the model is spread — see\nxref:architecture:data-modeling.adoc[data modelling].\n====\n\n== The workspace\n\nOpening a supplier gives a tab strip; each tab is a child route under\n`:supplierKey`. Tabs and their permission posture:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Tab | Route | Access\n\n| General\n| `general`\n| Denies `REST_UW_ACCESS` only — auditors may view.\n\n| Additional business owners\n| `additional-business-owners`, `…/new`\n| No extra restriction.\n\n| Summary\n| `summary/categorization`\n| Standard deny.\n\n| Onboarding\n| `onboarding`\n| Standard deny. → xref:supplier-onboarding.adoc[Onboarding]\n\n| Underwriting\n| `underwriting/{initial,daily,supplier-information,documentation,background-reports,bank-transactions-report,bank-transactions-categories,access-onboarding-predictions,access-daily-predictions,ucc/…}`\n| Denies `ADVANCE_AUDITORS` only; `background-reports` additionally requires\n  `FINANCE`; `documentation` is flag-gated.\n  → xref:supplier-underwriting.adoc[Underwriting]\n\n| New underwriting (beta)\n| `new-underwriting`\n| `FeatureFlagGuard`.\n\n| Payment\n| `payment/{list,new,edit/:paymentConfigKey,deposit-account-edit/:depositAccountKey}`\n| Standard deny. → xref:supplier-payments.adoc[Payments]\n\n| Fees\n| `fees/{purchase,payment,reserve,reserve/edit/:marketplaceKey}`\n| No extra restriction. → xref:fees-and-reserves.adoc[Fees]\n\n| Marketplaces\n| `marketplaces/{list,edit/:mpSupplierKey}`\n| Standard deny. → xref:marketplaces.adoc[Marketplaces]\n\n| Statements\n| `statements`\n| Standard deny + report metadata (`mp_statements` / `v_mp_statements`).\n\n| Reports\n| `reports/{list,:reportName}`\n| Standard deny. → xref:reports.adoc[Reports]\n\n| Advances\n| `advances/{'',new,advance/:advanceKey,advance-line/:advanceLineKey,pending-request/:requestKey,ml-offer/:mlOfferKey,progressive-details[/:advanceOfferRequestKey]}`\n| Standard deny. → xref:advances.adoc[Advances]\n\n| Amazon lending\n| `amazon-lending/{list,add-loan,loan/:loanKey}`\n| Standard deny; add/edit additionally need `MP_LOANS_MODIFY` / `MP_LOANS_DELETE`\n  via `SubPermissionsGuard`.\n\n| Health\n| `health`\n| Standard deny. Amazon account health; flag `SUPPLIER_ACCOUNT_HEALTH`.\n\n| Transaction summary / details\n| `transaction-summary`, `transaction-details`\n| Standard deny.\n\n| Receivables\n| `receivables`\n| Standard deny.\n\n| Card transactions\n| `card-transactions/efs`, `card-transactions/marqeta`,\n  `card-transactions/marqeta/:marqetaKey`\n| Standard deny (the Marqeta detail route has none).\n  → xref:cards.adoc[Cards]\n\n| Contracts\n| `contracts`\n| Standard deny. → xref:fees-and-reserves.adoc#contracts[Contracts]\n|===\n\n\"Standard deny\" = `except: [REST_UW_ACCESS, ADVANCE_AUDITORS]`. Full detail in\nxref:reference:routes.adoc[the route catalogue].\n\nSurrounding the tabs: the supplier box header (identity, status badges, quick\nactions), the supplier info panel and the CRM panel — the last two driven by the\n`layout` slice, which pins the CRM panel open on the General tab specifically. See\nxref:architecture:ui-composition.adoc[UI composition].\n\n== Loading a supplier\n\nNothing in the tab components triggers the load. The chain is:\n\n[source]\n----\nURL /#/suppliers/<key>/general\n  └─ SupplierKeyResolver              dispatches SetSupplierKey\n  └─ ROUTER_NAVIGATION\n       └─ SupplierEffects.navigateToSupplier\n            walks the route tree for a `supplierKey` param, then dispatches:\n              SET_SUPPLIER_KEY        point the supplier slice\n              SET_TARGET_UUID         point the CRM slice at the same key\n              FETCH_SUPPLIER_DETAILS\n                 └─ SupplierEffects.getSupplierDetails\n                      cache hit  → UPDATE_SUPPLIER_MODEL from state\n                      cache miss → forkJoin(\n                          SupplierService.fetchSupplierDetails(key, refresh),\n                          FundingParticularsService.fetchFundingParticulars(...),\n                          SupplierService.getSupplierBalance(...),\n                          forkJoin(LongTermContractsService.getContractsForSupplier(...),\n                                   …getHistoricalContractsForSupplier(...)))\n                        → assemble SupplierModel → UPDATE_SUPPLIER_MODEL\n----\n\nTwo behaviours to know:\n\nPermission-shaped loading::\nFor an `ADVANCE_AUDITORS` user, funding particulars, balance and both contract\ncalls are replaced with `of(null)`. The tuple shape is preserved, so\n`SupplierModel.currentFundingParticularsModel` and `.balance` are `null` rather\nthan absent. Components must tolerate that.\n\nCaching by supplier key::\n`supplier.suppliers` is a map of every supplier visited this session. Revisiting is\ninstant and *stale* — a refresh requires `FetchSupplierDetails({ key, refresh: true })`,\nwhich also appends `refreshTimestamp=Date.now()` to bust any HTTP-level cache.\n\nSecondary aggregates load on demand from their tabs:\n`FETCH_SUPPLIER_MARKETPLACES`, `FETCH_SUPPLIER_MARKETPLACE`,\n`FETCH_SUPPLIER_MARKETPLACE_DETAILS_PART`, `FETCH_SUPPLIER_PAYMENT_CONFIGS`,\n`FETCH_COMPLIANCE_ACTIONS_WITH_FILES`, `FETCH_CARD_PROVIDERS`,\n`FETCH_SUPPLIER_CARDS`, `FETCH_AIRWALLEX_STATUS` — each with the same\nalready-in-state check.\n\n[[partial-editing]]\n== Partial editing — the mechanism to understand\n\nThe supplier workspace does not submit whole forms. Each editable card owns a\n`formGroupKey` and saves independently. This is the single most important pattern in\nthe module.\n\n=== State\n\n`SupplierState` keeps three parallel maps keyed by `formGroupKey`:\n\n[source,typescript]\n----\nsupplierModelPartInEdit?:  { [subformId: string]: any };   // dirty, saving\nsupplierModelPartSaved?:   { [subformId: string]: any };   // last successful save\nsupplierModelPartErrors?:  { [subformId: string]: any };   // last failure\n----\n\n=== Flow\n\n[source]\n----\nCard \"Contact data\" (formGroupKey: 'contactData') → Save\n  dispatch UPDATE_MODEL_PART { formGroupKey, newValues, service, itemKey }\n     reducer: supplierModelPartInEdit['contactData'] = newValues\n     effect:  calls service.saveItemPart(itemKey, newValues, …)\n                (any ServiceUpdatePartialInterface implementation)\n       success → UPDATE_MODEL_PART_SUCCESS\n                   delete from …InEdit\n                   set    …Saved['contactData'] = newValues\n                   merge  newValues into a NEW SupplierDetailsModel\n                   merge  newValues into currentFundingParticularsModel too\n       failure → UPDATE_MODEL_PART_ERROR\n                   delete from …InEdit\n                   set    …Errors['contactData'] = error\n  card shows a success or error message, then dispatches\n  UPDATE_MODEL_PART_MAPS { formGroupKey, mapType: 'saved' | 'errors' } to clear it\n----\n\nThe UI half is `shared/card-formgroup/` plus\n`card-formgroup-message.service.ts`. The service half is\n`ServiceUpdatePartialInterface.saveItemPart(itemKey, itemPart, ...params)`, which\nis why the effect can call any supplier service uniformly.\n\n=== Consequences\n\n* A card's success/error state must be *cleared explicitly*\n  (`UPDATE_MODEL_PART_MAPS` with `mapType: 'saved'` or `'errors'`). An invalid\n  `mapType` throws `Error('Invalid map type')`.\n* `UPDATE_MODEL_PART_SUCCESS` merges the saved values into *both*\n  `SupplierDetailsModel` and `FundingParticularsModel`, because some cards edit\n  fields that live on the funding record. A key present on both is written to both.\n* Two cards editing overlapping fields will fight. Keep `formGroupKey` sets\n  disjoint.\n* The merge constructs `new SupplierDetailsModel(...)`, so the derived getters\n  survive this path — unlike the plain spreads elsewhere in the reducer.\n\n== Lifecycle toggles in the store\n\nThree toggles are modelled with the request/success/error triplet and an\nin-progress flag, so the button can disable itself:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action group | Effect on state\n\n| `TOGGLE_HIDE_SUPPLIER[_SUCCESS/_ERROR]`\n| `toggleHideInEdit`, `toggleHideError`. On success the reducer *flips*\n  `supplierDetailsModel.hide` locally rather than re-reading it.\n\n| `TOGGLE_SUSPEND_SUPPLIER[_SUCCESS/_ERROR]`\n| `toggleSuspendInEdit`, `toggleSuspendError`. No local model mutation.\n\n| `TOGGLE_LOCK_SUPPLIER[_SUCCESS/_ERROR]`\n| Actions and effects exist; the reducer has no cases for them, so the lock state\n  is only refreshed by re-fetching the supplier.\n|===\n\nOpt-in rows (`ADD_OPT_IN_ROW`, `DELETE_OPT_IN_ROW`, with `_SUCCESS`, `_ERROR` and\n`ADD_OPT_IN_ROW_CLEAR`) follow the same shape.\n\nPayment-config operations use a different idiom — a `paymentConfigsPending` map\nkeyed by a caller-supplied `actionId`, with `PaymentConfigStatus.SUCCESS | ERROR`,\ncleared by `PC_CLEAR_STATUS`. See xref:supplier-payments.adoc[payments].\n\nThe lifecycle *business* meaning is in\nxref:supplier-lifecycle.adoc[lifecycle actions].\n\n== Supplier list\n\n`suppliers/supplier-list/` is the browse surface. Two behaviours are worth knowing:\n\nQuery persistence::\n`supplier.lastQueryString` is persisted to `localStorage['suppliers_last_query']`\nand restored on load. If nothing is stored, the default is\n`created_since:<today − 1 day>` — which is why a fresh session shows only\nrecently-created suppliers, not an empty or unbounded list.\n\nSearch debounce::\n`SEARCH_DEBOUNCE_TIME = 800` ms, exported from `SupplierService`.\n\nGlobal search (the sidebar overlay) is separate — it hits the internal API's\n`search3` parameter. See\nxref:architecture:ui-composition.adoc[UI composition].\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Reducer mutation\n| `supplier.reducers.ts` mutates nested models in several cases\n  (`supplierDetailsModel.hide = !…`, `supplierModel.marketplaces[index] = …`,\n  and a `delete state.supplierModel.marketplaces` inside\n  `FETCH_SUPPLIER_MARKETPLACE`). Do not depend on reference changes for change\n  detection here.\n\n| `supplierKey` outside `payload`\n| `UpdateSupplierModel` takes `supplierKey` as a *second constructor argument*, and\n  the reducer reads `action.supplierKey`. Effects that emit plain object literals\n  must include it at the top level, not inside `payload`.\n\n| `ADD_PAYMENT_GATEWAY` resets state\n| It returns `{ ...initialState, supplierModel: changed }`, discarding the\n  `suppliers` cache, the part maps and `lastQueryString`. Adding a gateway\n  therefore drops other suppliers from the session cache.\n\n| `FETCH_SUPPLIER_DETAILS` also resets\n| It returns `{ ...initialState, suppliers, lastQueryString }` — the part-edit maps\n  and all progress flags are cleared whenever a load starts.\n\n| Auditor nulls\n| `currentFundingParticularsModel` and `balance` are `null`, not `undefined`, for\n  `ADVANCE_AUDITORS`. Guard with truthiness, not `isUndefined`.\n\n| Two box headers\n| `supplier-box-header/` and `supplier-box-header-old/` both exist with their own\n  services. Check which the template uses before editing.\n\n| EVA writes into this slice\n| `UPDATE_SUPPLIER_DETAILS_MODEL_BY_EVA` lets the embedded EVA front end patch\n  supplier details, and `AppComponent` pushes details back out on every change. A\n  field change has to be reflected on both sides. See\n  xref:platform:iframe-messaging.adoc[iframe messaging].\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:supplier-onboarding.adoc","title":"Onboarding and KYC","description":"Bringing a supplier from signed-up to fundable — templates, compliance actions, document verification, marketplace access and 2FA.","url":"https://docx.payability.com/internal-admin/domains/supplier-onboarding.html","navOrder":18,"source":"= Onboarding and KYC\n:description: Bringing a supplier from signed-up to fundable — templates, compliance actions, document verification, marketplace access and 2FA.\n\n== What it is\n\nOnboarding is the checklist that has to complete before a supplier can be funded:\ncollect and verify identity and business documents, obtain access to the supplier's\nmarketplace accounts, agree the product/service configuration, and sign the\ncontract. It is driven by an *onboarding template* selected per supplier and per\nmarketplace.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Supplier tab\n| `/suppliers/:supplierKey/onboarding` →\n  `suppliers/supplier/supplier-onboarding/supplier-onboarding.component.ts`\n\n| Service\n| `suppliers/supplier/supplier-onboarding/supplier-onboarding.service.ts`\n  (+ `-mock.service.ts`), implementing `SupplierOnboardingInterface`\n\n| Compliance\n| `suppliers/services/supplier-compliance-action.service.ts`,\n  `supplier-compliance-action-status.service.ts`,\n  `suppliers/model/compliance.model.ts`, `supplier-compilance-status.ts`\n\n| Templates (back office)\n| `/manage/onboarding` → `manage/onboarding/` with the `onboarding` store slice\n  (`manage/store/onboarding.{actions,reducer}.ts`) and\n  `shared/onboarding/service/onboarding.service.ts`\n\n| Sub-features\n| `supplier-onboarding-details/`, `supplier-onboarding-compliances/`,\n  `supplier-onboarding-marketplace-access/`,\n  `supplier-onboarding-marketplace-permissions/`, `supplier-sales-information/`,\n  `services-product-matrix/`, `smsnumbers/`, `supplier-verify-document-dialog/`,\n  `onboarding-process-modal/`, and five `marketplace-auth-*` action folders\n|===\n\n== Compliance actions and KYC\n\nOnboarding steps are modelled as *compliance actions*, each identified by a\nhardcoded UUID in `supplier-onboarding.service.ts`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Constant | Meaning\n\n| `KYC_IDENTITY_KEY`\n| Identity verification (`869f7aaf-…`)\n\n| `KYC_ADDRESS_KEY`\n| Address verification (`f9be5d82-…`)\n\n| `KYC_DOCUSIGN_KEY`\n| Contract signature via DocuSign (`9a3f6e03-…`)\n\n| `ACCOUNT_ACCESS_INVITATION_KEY`\n| Marketplace account-access invitation (`c5f8e904-…`)\n\n| `AMAZON_SELLER_CENTRAM_PERM_KEY`\n| Amazon Seller Central permission check (`331d90cc-…`)\n\n| `ONBOARDING_PROGRESS_KEY`\n| The aggregate progress record (`87b86b36-…`)\n\n| `ONBOARDING_ACTION_KEY` / `ONBOARDING_ACTION_TYPE`\n| The onboarding check action (`d24c3716-…`, type `onboarding_check`)\n|===\n\n`COMPLIANCE_ACTION_KEYS` groups the first five; the supplier effect\n`FETCH_COMPLIANCE_ACTIONS_WITH_FILES` loads their statuses plus attached files into\n`SupplierModel.kycDocumentsStatuses`.\n\n[WARNING]\n====\nThese UUIDs are *environment-independent constants embedded in the front end*. If a\ncompliance action is recreated in the backend with a new key, the front end silently\nstops finding it. Any change here is a coordinated backend + front-end change.\n====\n\nThe same file also hardcodes marketplace keys used for onboarding branching:\n`AMAZON_SELLER_CENTRAL_MARKETPLACE_KEY`, `TOPHATTER_…`, `WALLMART_…` (sic),\n`SHOPIFY_…`, `PAYONEER_…`, `MORECOMMERCE_…`, `EBAY_…`, `VOLUSION_…`, `PAYPAL_…`,\n`STRIPE_…`, `AUTHORIZE_…`, `TARGET_…`, `NEWEGG_…`, `PINGPONG_…`.\n`TARGET_MARKETPLACE_KEY` is imported by `SupplierDetailsModel.isTarget`.\n\n=== Compliance endpoints\n\nAll on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Use\n\n| `suppliers/:key/compliance_status`\n| Current status of every compliance action.\n\n| `suppliers/:key/compliance_status/:complianceKey/:action/:version`\n| A specific action's status at a version.\n\n| `suppliers/:key/compliance_snapshot/list`, `…/compliance_snapshot/:itemKey`\n| Point-in-time snapshots of the compliance picture.\n\n| `compliance_action_status/:version` (POST), `…/:statusKey/:version` (PUT)\n| Record or update an action status.\n\n| `compliance_status/:statusKey` (PUT)\n| Update the aggregate status.\n\n| `suppliers/:key/identity_check/sole/:partial`,\n  `…/identity_check/company/:partial`\n| Identity verification for sole traders vs companies — two distinct flows.\n\n| `suppliers/:key/check_amazon_api`\n| Verify Amazon API access still works.\n\n| `suppliers/:key/marqeta_kyc_compliances`,\n  `…/marqeta_kyc_compliances/owners`\n| Marqeta's own KYC requirements (GET/PUT for the business, GET/PUT for owners) —\n  required before a Marqeta card can be issued. See xref:cards.adoc[cards].\n|===\n\n== Document verification\n\n`supplier-verify-document-dialog/` plus `supplier-verify-document.service.ts` is the\nreviewer UI: an uploaded document is displayed and marked verified or rejected.\n`suppliers/model/documentation/` holds the document models, and\n`suppliers/services/documentation/documentation.service.ts` the retrieval.\n\nDocuments may live in any of four backends depending on age and type —\n`b2bDocumentsApiUrl`, `pybDocumentsApiUrl` (behind the `PYB_DOCUMENTS` flag),\n`documentsApiUrl` and `s3FilesProviderApi`. `shared/apps-document/`,\n`shared/b2b-document/` and `shared/pyb-documents/` are the three client wrappers.\n\n[NOTE]\n====\nWhich document backend a given attachment is in is not always inferable from the\nfront end. When a document fails to load, check all three wrappers before assuming a\nbug.\n====\n\n== Marketplace account access\n\nPayability needs read access to the supplier's marketplace account to see earnings.\nFive action folders implement that negotiation:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Folder | Action\n\n| `marketplace-auth-invite-resent/`\n| Re-send the access invitation to the supplier.\n\n| `marketplace-auth-code-get/`\n| Obtain the authorisation code to hand to the marketplace.\n\n| `marketplace-auth-code-save/`\n| Persist a code supplied by the supplier.\n\n| `marketplace-auth-recheck-perm/`\n| Re-verify that granted permissions are still in place.\n\n| `marketplace-auth-remove-access/`\n| Revoke Payability's access.\n|===\n\n`supplier-onboarding-marketplace-access/` and\n`supplier-onboarding-marketplace-permissions/` show the resulting state.\n\n== Services and product matrix\n\n`services-product-matrix/` configures which Payability products a supplier gets, per\nmarketplace. It is a grid of services against marketplaces:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Role\n\n| `product-matrix.service.ts` (+ mock)\n| Load and persist the matrix.\n\n| `product-matrix-initialize-tables.service.ts`\n| Build the initial table structure from the supplier's marketplaces and the\n  available products.\n\n| `flex-participation.service.ts` (+ mock)\n| Flex product participation per marketplace.\n|===\n\n== Sales information and SMS\n\n`supplier-sales-information/` captures estimated revenue and sales context used\ndownstream by underwriting.\n\n`smsnumbers/` provisions the SMS number used for supplier notifications and 2FA. It\nis one of the few features that spans two backends: `authToolsApiUrl` (provisioning)\nand `messagingUrl` (sending) — see `sms-numbers-provider.service.ts`.\n\n== Two-factor authentication\n\n`suppliers/supplier/supplier-2fa/` is its own Angular module\n(`supplier-2fa.module.ts`) with `supplier-2fa.service.ts`, gated by the `TWO_FA`\nfeature flag. It manages the supplier's 2FA enrolment from the operator side.\n\nRelated: `suppliers/supplier/supplier-change-password-dialog/` and\n`suppliers/supplier/supplier-unlock/` handle credential resets and unlocking, and\n`suppliers/services/supplier-auth-token.service.ts` issues supplier auth tokens via\n`authToolsApiUrl`.\n\n== Onboarding templates (back office)\n\n`/manage/onboarding` administers the templates themselves:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Screen\n\n| `manage/onboarding/list`\n| All templates.\n\n| `manage/onboarding/add`, `manage/onboarding/:onboardingTemplateKey/edit`\n| Create/edit a template — the ordered step list.\n\n| `manage/onboarding/:onboardingTemplateKey/marketplaces`\n| Which marketplaces the template applies to.\n|===\n\nState is the `onboarding` slice (`manage/store/onboarding.reducer.ts` — a small\nmap-of-handlers reducer with no effects); the API wrapper is\n`shared/onboarding/service/onboarding.service.ts` on `internalApiUrl`.\n\nA supplier's chosen template is recorded on the supplier as\n`onboardingTemplateCode`, and overall completion as `onboardingStatus`.\n\n== The EVA onboarding summary\n\nThe `EVA_ONBOARDING_SUMMARY` feature flag switches part of the onboarding view to an\nEVA-rendered iframe. When it is on, the summary you see is not this codebase — see\nxref:platform:iframe-messaging.adoc[iframe messaging and EVA]. The\n`ONBOARDING__INIT_LOAD_EARNINGS_PROCESS` message type lets EVA start an earnings load\nin the parent.\n\n== Business rules and constraints\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Rule | Detail\n\n| Sole trader vs company\n| Identity verification uses two different endpoints\n  (`identity_check/sole/…` vs `identity_check/company/…`); the branch is driven by\n  `businessTypeCode` / `incorporatedInTheUS`.\n\n| Amazon Seller Central is special-cased\n| It has its own permission-check compliance action and its own API health check\n  (`check_amazon_api`). Amazon Vendor accounts follow a different path — see\n  `isAmazonVendor`.\n\n| Marqeta KYC is separate from Payability KYC\n| A supplier can pass Payability's KYC and still fail Marqeta's. Both must pass\n  before a Marqeta card is issued.\n\n| Onboarding does not gate the UI\n| `onboardingStatus` is informational in the front end. Nothing in the routing\n  prevents opening the advances tab for an un-onboarded supplier; the backend\n  rejects the operation.\n|===\n\n== Gotchas\n\n* The service file name is `supplier-onboarding.service.ts`, but stale build\n  residue `supplier-oboarding.service.js` (missing the `n`) also exists. Ignore the\n  `.js`.\n* JSON fixtures (`supplier-action-01.json` … `-04.json`,\n  `supplier-onboarding-uploads.json`) sit in the feature folder and are used by the\n  mock service and specs, not at runtime.\n* `WALLMART_MARKETPLACE_KEY` is misspelled in source. Match the existing spelling\n  when referencing it.\n* `FETCH_COMPLIANCE_ACTIONS_WITH_FILES` maps each result array to its first element\n  (`actionsArr[0]`), so only the most recent status per action reaches the store.\n"},{"component":"internal-admin","module":"domains","path":"domains:supplier-underwriting.adoc","title":"Underwriting","description":"Deciding whether and how much to fund — initial and daily underwriting, background reports, UCC filings, and the tooling around them.","url":"https://docx.payability.com/internal-admin/domains/supplier-underwriting.html","navOrder":19,"source":"= Underwriting\n:description: Deciding whether and how much to fund — initial and daily underwriting, background reports, UCC filings, and the tooling around them.\n\n== What it is\n\nUnderwriting is the credit and risk assessment. *Initial* underwriting decides\nwhether to fund a new supplier at all and produces the inputs to the first offer.\n*Daily* underwriting re-evaluates an active supplier as new marketplace and bank\ndata arrives.\n\nThe output that matters downstream is a *risk rating*, which drives the default\nprogressive-pricing period table in xref:advances.adoc[the advance calculator].\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/suppliers/:supplierKey/underwriting/*` in\n  `suppliers/suppliers-routing.routes.ts`\n\n| Feature root\n| `suppliers/supplier/supplier-underwriting/`\n\n| Beta rewrite\n| `suppliers/supplier/supplier-underwriting-beta/` at\n  `/suppliers/:supplierKey/new-underwriting`, behind `FeatureFlagGuard`\n\n| Service\n| `supplier-underwriting/supplier-underwriting.service.ts` — spans\n  `internalApiUrl` *and* `automaticMarketplaceApiUrl`\n\n| Store\n| `supplier-underwriting/store/` (the `underwriting` slice) and\n  `suppliers/store/background-reports/` (the `backgroundReports` slice)\n\n| Cross-supplier queue\n| `underwriting/tracker/` — see xref:underwriting-tracker.adoc[UW tracker]\n|===\n\n== Route structure and access\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Screen\n\n| `underwriting` (parent)\n| `SupplierUnderwritingComponent`. Denies `ADVANCE_AUDITORS` only — restricted-UW\n  users *are* allowed here (`RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG`).\n\n| `underwriting/initial`\n| `UnderwritingInitialComponent` — the default child.\n\n| `underwriting/daily`\n| `UnderwritingDailyComponent`. Standard deny (`REST_UW_ACCESS` +\n  `ADVANCE_AUDITORS`).\n\n| `underwriting/supplier-information`\n| `SupplierInformationComponent`.\n\n| `underwriting/documentation`\n| `DocumentationComponent`, flag-gated via `FeatureFlagGuard`.\n\n| `underwriting/background-reports`\n| `BackgroundReportsComponent`. Requires `only: FINANCE` — the *only* other\n  allow-list clause in the app besides `/admin`.\n\n| `underwriting/bank-transactions-report`\n| `UnderwritingReportTabComponent`, `data.reportId = ExternalReports.PLAID`\n  (`PLAID-D`).\n\n| `underwriting/bank-transactions-categories`\n| Same component, `BANK_CATEGORIES` (`PL-BT`). Flag: `BANK_CATEGORIES`.\n\n| `underwriting/access-onboarding-predictions`\n| Same component, `ACCESS_ONBOARDING_PREDICTIONS` (`BR-AC-ON`). Flag of the same\n  name.\n\n| `underwriting/access-daily-predictions`\n| Same component, `ACCESS_DAILY_PREDICTIONS` (`BR-AC-DL`). Flag of the same name.\n\n| `underwriting/ucc/{list,create,edit/:id,transUnion/:id}`\n| `UccMainComponent` and children. Standard deny.\n|===\n\nFour routes share `UnderwritingReportTabComponent`, differentiated only by\n`data.reportId`. That is the \"one component, many routes\" pattern — see\nxref:architecture:routing.adoc[routing].\n\n== Initial underwriting\n\n`underwriting-initial/` is decomposed into the assessment areas an underwriter works\nthrough:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Card | Assesses\n\n| `amazon-information/`, `amazon-api/`\n| Amazon account identity and whether API access is healthy.\n\n| `amazon-revenue/`\n| Revenue history from the marketplace.\n\n| `amazon-advertising/`\n| Advertising spend as a signal.\n\n| `amazon-lending/`\n| Existing Amazon Lending loans — competing debt.\n\n| `account-health/`\n| Marketplace account-health metrics (see xref:#health[Amazon health rules]).\n\n| `credit-check/`\n| Business credit.\n\n| `personal-background-check/`\n| Owner background checks (see xref:#background[background reports]).\n\n| `international-supplier/`\n| Extra checks for non-US suppliers (`incorporatedInTheUS === false`).\n\n| `agreement-review/`\n| Contract/agreement review step.\n\n| `underwriting-files/`\n| Documents attached to the underwriting.\n\n| `initial-underwriting-history/`\n| Prior underwriting decisions.\n\n| `review-final-offer-dialog/`\n| The final confirm-before-offer dialog.\n\n| `underwriting-initial-header/`, `-footer/`\n| Status summary and the decision actions.\n\n| `common/services/underwriting-initial.service.ts`\n| Shared loading/persistence for the above.\n|===\n\n=== Underwriting status vocabulary\n\n`suppliers/enums/uw-status.ts`:\n\n[source,typescript]\n----\nexport enum UwStatus {\n  VERIFIED          = 'verified',\n  INCOMPLETE        = 'incomplete',\n  PENDING           = 'pending',\n  REJECTED          = 'rejected',\n  SALES_RESOLUTION  = 'sales resolution',\n}\n----\n\n`SALES_RESOLUTION` is the \"blocked pending a sales conversation\" state — not a\ncredit decision. `uwStatus` is also a field on the advance calculator state, which\nis how an underwriting outcome reaches offer pricing.\n\n=== The tools gate\n\nThe `underwriting` store slice holds only `{ toolsLoaded, tools: UwToolEntity[] }`,\nloaded by `UnderwritingFetchTools` → `…Success` / `…Failed`\n(`UNDERWRITING_ACTION` — note this slice uses `'[UNDERWRITING] …'`-prefixed action\nstrings, unlike the older slices).\n\nThe `UNDERWRITING_CHECK_TOOLS_BEFORE_APPROVING` feature flag makes the presence of\nthese tool results a precondition for approval:\n`suppliers/supplier/underwriting-tools/` renders them. With the flag off, approval is\nnot blocked on tool checks.\n\n== Daily underwriting\n\n`underwriting-daily/` re-evaluates active suppliers. Structure:\n\n* `underwrating-daily-table/` (sic) — the grid.\n* `underwriting-daily-edit/` — editing a day's assessment.\n* `uw-daily-data-source.ts` — the grid data source.\n* `consts/underwriting-daily-columns.ts` — the column definition.\n\n[[health]]\n== Amazon health rules\n\nTwo endpoints on `internalApiUrl`:\n\n[source]\n----\nGET suppliers/:key/amazon_health_rules      current rules and evaluations\nPUT suppliers/:key/amazon_health_rules      update the rule configuration\nGET suppliers/:key/amazon_health            the health snapshot (SupplierService)\n----\n\nThe dedicated tab is `/suppliers/:key/health`\n(`suppliers/supplier/supplier-health/`), gated by the `SUPPLIER_ACCOUNT_HEALTH`\nfeature flag. Account health also appears as a card inside initial underwriting.\n\n[[background]]\n== Background reports\n\n`suppliers/supplier/supplier-underwriting/background-reports/` with\n`suppliers/services/background-reports/background-reports.service.ts` and the\n`backgroundReports` store slice\n(`suppliers/store/background-reports/`).\n\nNote that the effects file for this slice is *empty* — the reducer is registered but\nloading happens through the service directly.\n\nReport types are enumerated in\n`supplier-underwriting/consts/all-background-report-types.ts` and\n`background-report-type.ts`; content sources in `content-source.ts` /\n`content-sources.ts`. Access requires `FINANCE`.\n\n== External report tabs\n\nFour underwriting tabs are thin wrappers over the internal API's *external report*\nendpoints, identified by `ExternalReports` codes\n(`shared/external-report/external-reports.enum.ts`):\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Enum | Code | Content\n\n| `PLAID`\n| `PLAID-D`\n| Bank transactions pulled via Plaid.\n\n| `BANK_CATEGORIES`\n| `PL-BT`\n| Categorised bank transactions.\n\n| `ACCESS_ONBOARDING_PREDICTIONS`\n| `BR-AC-ON`\n| ML predictions computed at onboarding.\n\n| `ACCESS_DAILY_PREDICTIONS`\n| `BR-AC-DL`\n| ML predictions recomputed daily.\n|===\n\nFetching goes through `reports/service/external-reports.service.ts`\n(`reports/external_list`, `reports/external/:keys`) and `shared/external-report/`.\nThe ML prediction tabs are each behind a feature flag of the same name, so an\nenvironment can expose bank data without exposing model output.\n\n== Real-time validation\n\n`supplier-underwriting/real-time-validation/` provides input-level validation against\nthe *automatic underwriting tool* (`automaticMarketplaceApiUrl`) while the\nunderwriter types:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| File | Role\n\n| `real-time-validator.service.ts` (+ mock)\n| Calls the UW tool and returns validation verdicts.\n\n| `real-time-input-factory.service.ts` (+ mock)\n| Builds the validated input controls dynamically.\n\n| `real-time-validator.interfaces.ts`\n| The verdict contract.\n\n| `base-underwriting-component/`\n| Base class wiring a component into the validation pipeline.\n\n| `uw-personal-checks-card/` + `consts/personal-checks-column-names.ts`\n| The personal-checks card that uses it.\n|===\n\nBoth the validator and the input factory have mock implementations and specs — this\nis one of the better-tested areas of the module.\n\n== UCC filings\n\nA UCC filing records Payability's lien against the supplier's assets. Everything on\n`internalApiUrl` under `suppliers/:key/ucc/`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Use\n\n| `GET ucc/filing`\n| List filings (with query params).\n\n| `GET ucc/filing/:uccKey`\n| One filing.\n\n| `POST ucc/filing`\n| Create.\n\n| `PUT ucc/filing/:uccKey`\n| Update.\n\n| `DELETE ucc/filing/:uccKey`\n| Delete (with a body).\n\n| `GET ucc/data`, `GET ucc/data/:uccKey`\n| Supporting data for a filing.\n|===\n\nScreens: `ucc-list/`, `ucc-create/` (used for both create and `edit/:id`),\n`ucc-trans-union/` at `transUnion/:id` — the TransUnion lookup used to populate or\nverify filing details. There is also a sibling `trans-union/` folder at the\nunderwriting root.\n\nModel: `ucc-main/ucc.model.ts`.\n\n== Marqeta KYC in underwriting\n\n`supplier-underwriting.service.ts` also owns the Marqeta compliance calls\n(`suppliers/:key/marqeta_kyc_compliances` and `…/owners`, GET and PUT). Marqeta\nrequires its own KYC pass on both the business and each owner before a card can be\nissued — independent of Payability's own KYC. See xref:cards.adoc[cards].\n\n== Compliance snapshots\n\n`compliance_snapshot/list` and `compliance_snapshot/:itemKey` give point-in-time\nviews of the compliance picture — used to show what was known at the time a decision\nwas made, rather than the current state. Useful for audit questions.\n\n== The beta rewrite\n\n`supplier-underwriting-beta/` at `/suppliers/:key/new-underwriting` is a\nflag-gated reimplementation. Both are live. Before changing underwriting behaviour,\ndetermine which surface the change belongs to — and whether it needs to be made\ntwice.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Inverted access on the parent route\n| `underwriting` allows `REST_UW_ACCESS` (it only denies `ADVANCE_AUDITORS`), while\n  most children deny both. A restricted-UW user can reach the parent and then be\n  bounced from `daily` and `ucc`.\n\n| `FINANCE` is an allow-list\n| `background-reports` uses `only: FINANCE`, so *every* non-finance user is blocked,\n  including super users unless they also hold `FINANCE`.\n\n| Empty effects file\n| `background-reports.effects.ts` is an empty file yet the slice is registered.\n  Do not look for effects that are not there.\n\n| Folder typos\n| `underwrating-daily-table` and `underwriting-initial` sibling naming are\n  inconsistent. Match existing spellings when importing.\n\n| Two automatic-UW hosts\n| `supplier-underwriting.service.ts` mixes `internalApiUrl` and\n  `automaticMarketplaceApiUrl` in one class. Check which base URL a method uses\n  before adding to it.\n\n| Flags stack\n| `documentation`, `bank-transactions-categories`,\n  `access-onboarding-predictions` and `access-daily-predictions` are each\n  independently flagged, so the visible tab set differs per environment. See\n  xref:reference:feature-flag-catalog.adoc[the flag catalogue].\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:advances.adoc","title":"Advances and offers","description":"The advance lifecycle and the progressive-pricing calculator — the largest piece of genuine business arithmetic in the front end.","url":"https://docx.payability.com/internal-admin/domains/advances.html","navOrder":20,"source":"= Advances and offers\n:description: The advance lifecycle and the progressive-pricing calculator — the largest piece of genuine business arithmetic in the front end.\n\n== What it is\n\nAn *advance* is cash paid to a supplier ahead of marketplace remittance and repaid\nout of subsequent remittances. An *advance line* is the revolving version of the\nsame facility.\n\nGetting there means pricing an *advance offer request*: an amount, a risk rating,\nand a schedule of fee *periods*. The pricing arithmetic runs in the browser — this\nis the one area where the front end genuinely computes money numbers before\nsubmitting them.\n\n[IMPORTANT]\n====\nThe browser computes the proposal. The backend validates and books it. Everything on\nthis page describes what the operator sees and submits, not the system of record.\n====\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/suppliers/:supplierKey/advances/*`\n\n| Feature root\n| `suppliers/supplier/supplier-advances/`\n\n| Service\n| `supplier-advances/service/supplier-advances.service.ts` (`internalApiUrl`,\n  mostly under `supplier_instant_advance/`)\n\n| Calculator store\n| `supplier-advances/pending-offer/pending-offer-calculator/store/` — the `calc`\n  slice\n\n| Calculator services\n| `…/pending-offer-calculator/service/periods-calculations.service.ts`,\n  `pending-offer-risk-rating.service.ts`, `calculator-data-fixture.ts`\n\n| Own module\n| `pending-offer-calculator/pending-offer-calculator.module.ts`\n|===\n\n== Routes and screens\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Screen\n\n| `advances` (`''` child)\n| `BaseAdvancesComponent` — the overview: requested, approved, active advances and\n  advance lines. Sub-folders `requested-advances/`, `approved-advances/`,\n  `approved-advance-lines/`, `active-advance/`, `underwritings/`.\n\n| `advances/new`\n| `NewOfferComponent` — start a new offer.\n\n| `advances/advance/:advanceKey`\n| `ActiveAdvanceDetailsComponent`.\n\n| `advances/advance-line/:advanceLineKey`\n| `ActiveAdvanceLineDetailsComponent`.\n\n| `advances/pending-request/:requestKey`\n| `PendingOfferComponent` — the pricing screen, host of the calculator.\n\n| `advances/ml-offer/:mlOfferKey`\n| `OfferEligibilityDetailsComponent` — the ML-generated offer.\n\n| `advances/progressive-details[/:advanceOfferRequestKey]`\n| `InstantAdvanceUnderwritingComponent`.\n|===\n\nAll standard-deny (`REST_UW_ACCESS`, `ADVANCE_AUDITORS` excluded) at the\n`advances` parent.\n\n== The lifecycle\n\n[source]\n----\n   ML eligibility                 request                 underwriting\n ┌────────────────┐         ┌─────────────────┐      ┌──────────────────┐\n │ ml_offer       │────────►│ create_request  │─────►│ update_uw_request│\n │ ml_offer_details│        │ advance_request │      │ manually_update_ │\n │ pyb_eligibility│         └─────────────────┘      │ request_uw_data  │\n │ ranking        │                                  └────────┬─────────┘\n └────────────────┘                                           │\n                                                              ▼\n   repayment / recovery         booked            ┌──────────────────────┐\n ┌────────────────────┐   ┌──────────────────┐    │ pricing (calculator) │\n │ advance_liability  │◄──│ active_advance   │◄───│ update_offer_request │\n │ requests_history   │   │ advance_line     │    │ periods, fee, reserve│\n │ advance_line_history│  └──────────────────┘    └──────────────────────┘\n └────────────────────┘             │\n                                    ▼\n                        ┌──────────────────────────┐\n                        │ re_offer_eligibility     │\n                        │ re_offer                 │\n                        │ retract (proposed line)  │\n                        └──────────────────────────┘\n----\n\n=== Endpoints\n\nAll on `internalApiUrl`. Most sit under `supplier_instant_advance/`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `POST supplier_instant_advance/:key/create_request`\n| Create an offer request.\n\n| `GET supplier_instant_advance/:key/advance_request`\n| The pending request.\n\n| `GET supplier_instant_advance/:key/active_advance`\n| The currently active advance.\n\n| `GET supplier_instant_advance/:key/advance_liability`\n| Outstanding liability.\n\n| `GET suppliers/:key/supplier_advances`\n| All advances for the supplier.\n\n| `GET supplier_instant_advance/:key/ml_offer`,\n  `…/ml_offer_details`, `supplier_instant_advance/offer_details`\n| ML-generated offer and its detail.\n\n| `GET supplier_instant_advance/:key/re_offer_eligibility`,\n  `POST …/:key/re_offer`\n| Re-offer eligibility and submission.\n\n| `GET supplier_instant_advance/:key/requests_history`\n| Past requests.\n\n| `POST supplier_instant_advance/update_uw_request`\n| Update the underwriting portion of a request.\n\n| `POST supplier_instant_advance/update_offer_request`\n| Update the priced offer.\n\n| `POST supplier_instant_advance/:key/manually_update_request_uw_data/:advanceOfferRequestKey`\n| Manual override of UW data on a request.\n\n| `GET supplier_instant_advance/:key/advance_line_history`,\n  `…/advance_line/:key/last`, `…/advance_line/:advanceLineKey`\n| Advance-line history and current state.\n\n| `GET suppliers/:key/advance_line/:advanceLineKey/uw_history`,\n  `PATCH suppliers/:key/advance_line/:advanceLineKey/update`\n| Advance-line underwriting history and update.\n\n| `POST supplier_instant_advance/:proposedAdvanceLineKey/retract`\n| Retract a proposed advance line.\n|===\n\n== ML offer eligibility\n\n`IEligibilityData` (`store/calculator-interfaces.ts`) is what the model returns:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `pyb_eligibility`\n| Payability's computed eligibility figure.\n\n| `offer_amount`\n| The model's recommended amount.\n\n| `ranking`\n| The model's ranking band.\n\n| `delivery_rate`, `cancellation_rate`, `order_defect_rate`\n| Marketplace performance inputs.\n\n| `days_ok_count`\n| Count of days meeting the threshold.\n\n| `no_data`\n| True when the model had insufficient input — the UI must handle this rather than\n  showing zeros.\n\n| `ml_offer_key`\n| Identifier used by the `ml-offer/:mlOfferKey` route.\n|===\n\nLoading eligibility dispatches `SET_ELIGIBILITY_DATA`, which also seeds\n`netDeliveryAmount` from `offer_amount` and the current total fee.\n\n== The progressive-pricing calculator\n\nThis is the `calc` state slice. It is unusual in two ways: it is a\n*map-of-handlers* reducer (a `Map` from action type to handler function rather than a\n`switch`), and it is the only slice with substantial arithmetic.\n\n=== The model\n\nPricing is a list of *periods*, each with a length (weeks) and a weekly fee\npercentage. Up to three periods, giving a stepped fee schedule.\n\n[source,typescript]\n----\nexport class Period { periodLength: number; fee: number; }\n----\n\nDerived values (`PeriodsCalculationsService`):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Value | Formula\n\n| `totalPeriodLength`\n| `Σ periodLength`\n\n| `totalFee`\n| `Σ (fee × periodLength)`\n\n| `averageFee`\n| `totalFee / totalPeriodLength` (0 if either is falsy)\n\n| rounding\n| `standardRound(v, precision = 3)` — `Math.round(v × 10^p) / 10^p`. Money\n  conversions use precision 2; period/fee totals use the default 3.\n|===\n\n=== The two money formulas\n\n[source,typescript]\n----\n// approved → net delivered to the supplier\ncalculateNetDeliveryAmount(approvedAmount, totalFee) {\n  if (isUndefined(approvedAmount)) return undefined;\n  if (!totalFee)                   return approvedAmount;\n  return standardRound(((100 - totalFee) * approvedAmount) / 100, 2);\n}\n\n// net delivered → gross/approved (the inverse)\ncalculateApprovedAmount(netDeliveryAmount, totalFee) {\n  return standardRound((netDeliveryAmount / (100 - totalFee)) * 100, 2);\n}\n----\n\nSo `totalFee` is a *percentage of the gross advance*, deducted up front. The two\nfunctions let the operator work from either end: type an approved amount and see the\nnet, or type a net and see the required gross.\n\n[CAUTION]\n====\n`calculateApprovedAmount` divides by `100 - totalFee` with no guard. A `totalFee` of\nexactly 100 yields `Infinity`. In practice `isTotalFeeIncorrect` flags anything above\n`MAX_TOTAL_FEE` (40), so the UI blocks submission — but the computed value is still\ndisplayed.\n====\n\n=== Business limits\n\nDeclared as constants in `calculator.reducers.ts`:\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Constant | Value | Rule\n\n| `MIN_TOTAL_FEE`\n| 5\n| Total fee below 5 % → `isTotalFeeIncorrect`.\n\n| `MAX_TOTAL_FEE`\n| 40\n| Total fee above 40 % → `isTotalFeeIncorrect`.\n\n| `MAX_TOTAL_PERIOD_LENGTH`\n| 30\n| Total length above 30 → `isTotalPeriodToLong`.\n\n| `MIN_PERIOD_COUNT`\n| 1\n| Cannot remove the last period (`isRemoveDisabled`).\n\n| `MAX_PERIOD_COUNT`\n| 3\n| Cannot add a fourth period (`isAddDisabled`).\n\n| `INITIAL_RESERVE_RATIO`\n| 25\n| Default repayment reserve percentage.\n\n| `MAX_RESERVE_RATIO`\n| 40\n| Reserve input is *clamped* to `[0, 40]` — values outside are silently corrected,\n  not rejected.\n|===\n\n`SET_RESERVE_FOR_REPAYMENTS` also sets `isMpReserveForPaymentEmpty` when the clamped\nratio is `<= 0`.\n\n=== Risk-rating defaults\n\n`PeriodsCalculationsService.getPeriodsFromDefaults(settings, riskRating)` picks the\ndefault period table:\n\n[source]\n----\nriskRating <  -1                          → undefined (no defaults)\nriskRating === -1                         → settings.WhenNoRiskRating\nriskRating >= settings.ForEveryRiskRating.length → undefined\notherwise                                 → settings.ForEveryRiskRating[riskRating]\n----\n\n`-1` is the sentinel for \"no rating\", and it is the *initial* value of\n`underwritingRiskRating`. The settings themselves come from\n`PendingOfferRiskRatingService.getDefaultProgressiveAdvanceSettings()`, which\ncurrently returns the local fixture `ProgressivePricingSettings` from\n`service/calculator-data-fixture.ts` rather than a backend call.\n\n[NOTE]\n====\nThe default pricing tables are a *committed front-end fixture*, not configuration.\nChanging default pricing per risk rating means changing\n`calculator-data-fixture.ts` and shipping the app.\n====\n\n=== Calculator state\n\n`CalculatorState` (`store/calculator-interfaces.ts`) carries, besides the periods and\namounts:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `requested`, `recommended`\n| `AdvanceOfferConnectedInputs` — the supplier's request and the system's\n  recommendation, each with `givenAmount`, `grossAmount`, `netDeliveryAmount`,\n  `reserveAmount`, `riskRanking` plus human-readable `description*` strings.\n\n| `approvedAmount`, `netDeliveryAmount`, `reserveForRepayments`\n| The offer being built.\n\n| `gracePeriodEndDate` / `gracePeriodSavedEndDate`\n| A repayment grace period, tracked as current-vs-saved so the UI can show unsaved\n  changes.\n\n| `noRebate` / `noRebateSaved`\n| Suppress rebate.\n\n| `returnStrategy` / `returnStrategySaved`\n| Default `'no_return'`.\n\n| `forceFull` / `forceFullSaved`\n| Default `true` — force full repayment.\n\n| `expectedMonthlyEarnings`, `expectedDuration`\n| Inputs to duration expectations.\n\n| `uwStatus`\n| `UwStatus`, initial `PENDING` — links pricing to\n  xref:supplier-underwriting.adoc[underwriting].\n\n| `isReadonly`, `isTouched`, `isNewOffer`\n| UI mode flags. `SET_TO_READONLY` / `SET_TO_EDITABLE` switch the first.\n\n| `isEmptyFee`, `isEmptyPeriodLength`, `isTotalFeeIncorrect`,\n  `isTotalPeriodToLong`, `isAddDisabled`, `isRemoveDisabled`,\n  `isMpReserveForPaymentEmpty`\n| Derived validation flags, recomputed by `compute()` on every period change.\n|===\n\nThe `*Saved` shadow fields exist because the offer is edited in place against a\npersisted record; the pair lets the UI mark dirty fields without a separate form\nstate.\n\n=== Reset semantics\n\nTwo resets, and the difference matters:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | Effect\n\n| `CALCULATOR_RESET`\n| Returns to `initialState`.\n\n| `CALCULATOR_HARD_RESET`\n| Returns to `initialState` but *preserves*\n  `defaultProgressiveAdvanceSettings` — so the (expensive to obtain) pricing tables\n  survive.\n|===\n\n`SET_STATE_FROM_PERSISTED_DATA` rehydrates from a saved offer: it runs `compute()`\nover the persisted periods and sets the risk rating.\n\n=== Submission\n\n`PendingOfferRiskRatingService.prepareDataToSave(state, requestKey?)` builds a\n`ProgressivePricingOfferModel`, sets `SerializeKeysTo(UnderscoreCase)` and serialises\nit. Two paths:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `saveProgressivePricingOffer(state, supplierKey)`\n| New offer → `createOfferRequest` (`POST …/create_request`).\n\n| `updateProgressivePricingOffer(state, requestKey)`\n| Existing offer → `postRequest` (`POST …/update_offer_request`), and *overrides*\n  `approvedAmount` with `+state.netDeliveryAmount` before serialising.\n|===\n\n[WARNING]\n====\nOn update, `approvedAmount` is replaced by `netDeliveryAmount`. On create it is not.\nThe two paths therefore submit different semantics for the same field — verify\nagainst the backend contract before changing either.\n\nAlso note `weeklyFeePercent` is hardcoded to `1` and `extraReservePercent` is set\nfrom `state.reserveForRepayments` (a percentage) in `prepareDataToSave`.\n====\n\n== Instant-advance underwriting\n\n`pending-offer/pending-offer-calculator/pending-offer-progressive-details/instant-advance-underwriting/`\n(routed as `advances/progressive-details[/:advanceOfferRequestKey]`) is the screen\nthat pairs the calculator with the underwriting inputs, and the caller of\n`manually_update_request_uw_data`.\n\n`advance-offer-inputs-modal/` is the dialog for editing the connected inputs\n(requested/recommended amounts and their descriptions).\n\n== Offer status\n\n`OfferStatuses` (`store/calculator.enums.ts`) contains a single member:\n\n[source,typescript]\n----\nexport enum OfferStatuses { PROCESSED = 'processed' }\n----\n\nOther offer statuses arrive as raw strings on `IAdvanceOfferRequest.offer_status` and\n`uw_status`. Do not assume the enum is exhaustive.\n\n== Testing\n\n`store/calculator.spec.ts` (~560 lines) is the most thoroughly tested unit in the\nrepository and is the reference for how to test a reducer here. If you change any\nformula or limit, that spec is where it must be proven.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Map-of-handlers reducer\n| `calculatorReducers` dispatches through a `Map`, and uses an\n  `isWithPayload(action)` type guard. Adding an action means adding a\n  `calculatorMap.set(TYPE, handler)` entry, not a `case`.\n\n| Clamping, not validating\n| Reserve ratio is silently clamped to `[0, 40]`. The operator's typed value may\n  differ from what is submitted.\n\n| `CurrencyPipe` inside the reducer\n| The reducer instantiates `new CurrencyPipe('en-US')` to build the\n  `description*` strings. Locale is hardcoded and formatting happens in state, not\n  the template.\n\n| Fixture-backed defaults\n| Default pricing per risk rating is a committed fixture, not a setting.\n\n| `-1` means \"no rating\"\n| Not `0`, not `null`. `0` is a valid rating that indexes\n  `ForEveryRiskRating[0]`.\n\n| `reserveForRepayments` is a percentage\n| Despite the name it holds a ratio (25 = 25 %), and it is submitted as\n  `extraReservePercent`.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:supplier-payments.adoc","title":"Supplier payments and deposit accounts","description":"How money leaves Payability for a supplier — payment configurations, deposit accounts, bank verification and payment features.","url":"https://docx.payability.com/internal-admin/domains/supplier-payments.html","navOrder":21,"source":"= Supplier payments and deposit accounts\n:description: How money leaves Payability for a supplier — payment configurations, deposit accounts, bank verification and payment features.\n\n== What it is\n\nTwo directions, two concepts:\n\n*Outgoing payment configuration* (`paymentConfigKey`)::\nHow Payability pays the supplier — the method (ACH, wire, prepaid card, …) and its\nparameters. A supplier may have several.\n\n*Deposit account* (`depositAccountKey`)::\nThe supplier's bank account. `SupplierDetailsModel.depositAccountKey` points at the\nprimary one.\n\nThe tab also carries *payment features* (auto-payment settings), *velocity limits*\nand, for card programmes, JIT (just-in-time funding) simulation.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/suppliers/:supplierKey/payment/{list,new,edit/:paymentConfigKey,deposit-account-edit/:depositAccountKey}`\n\n| Components\n| `suppliers/supplier/supplier-payment/` — `supplier-payment-list/`,\n  `outgoing-payment-add/`, `incoming-payment-add/`\n\n| Services\n| `suppliers/services/payment-config/payment-config.service.ts` (+ mock),\n  `suppliers/services/deposit-account/deposit-account.service.ts` (+ mock),\n  `suppliers/services/auto-payment.service.ts`,\n  `supplier-payment/outgoing-payment-add/payment-history.service.ts`,\n  `supplier-payment/supplier-payment-list/supplier-payment-plaid-information/supplier-plaid.service.ts`\n  (+ mock)\n\n| Models\n| `suppliers/model/payment-config.model.ts`,\n  `deposit-account.model.ts`,\n  `supplier-manual-bank-account.model.ts`,\n  `transfer-payment-details.model.ts`\n\n| Store\n| The `paymentConfigsPending` map in the `supplier` slice\n|===\n\nBoth `PaymentConfigService` and `DepositAccountService` are provided by explicit\ntoken in `AppModule` (`{ provide: X, useClass: X }`) so specs can substitute them.\n\n== Routing note\n\n`new`, `edit/:paymentConfigKey` and `deposit-account-edit/:depositAccountKey` each\ncarry their *own* `NgxPermissionsGuard` with the standard deny clause, in addition to\nthe parent's. Editing a payment method is therefore double-gated. `list` is not.\n\nAlso note `edit/:paymentConfigKey` and `new` share\n`OutgoingPaymentAddComponent` — the component distinguishes create from edit by the\npresence of the route parameter.\n\n== Payment configuration endpoints\n\nAll on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET suppliers/:key/payment/configs`\n| List a supplier's payment configurations.\n\n| `GET suppliers/:key/payment/configs/:paymentConfigKey`\n| One configuration.\n\n| `POST suppliers/:key/payment/configs`\n| Create (body serialised with `cerialize`).\n\n| `PUT suppliers/:key/payment/configs/:paymentConfigKey`\n| Update.\n\n| `DELETE suppliers/:key/payment/configs/:paymentConfigKey`\n| Delete.\n\n| `GET/PUT suppliers/:key/velocity/limits`\n| Spending velocity limits for the supplier.\n\n| `POST suppliers/:key/payment/payment_features`\n| Auto-payment / payment feature flags (`AutoPaymentService`, via\n  `saveItemPart` — it implements the partial-update interface).\n|===\n\n=== JIT simulation\n\n`PaymentConfigService` exposes five simulation endpoints used to exercise\njust-in-time card funding without real transactions:\n\n[source]\n----\nPOST suppliers/:key/jit/simulate/pending\nPOST suppliers/:key/jit/simulate/rejected\nPOST suppliers/:key/jit/simulate/charge\nPOST suppliers/:key/jit/simulate/cleared\nPOST suppliers/:key/jit/simulate/refund\n----\n\nThese are operator/QA tools for the card programmes — see xref:cards.adoc[cards].\nThey post `null` bodies and are safe only on non-production environments; the\nbackend enforces that, not the client.\n\n== Deposit account endpoints\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET deposit_accounts/:depositAccountKey`\n| Read.\n\n| `POST deposit_accounts`\n| Create.\n\n| `PUT deposit_accounts/:depositAccountKey`\n| Update.\n\n| `POST deposit_accounts/:depositAccountKey`\n| A second POST on the keyed URL — used for a document/action submission rather\n  than creation.\n|===\n\n`DepositAccountService` is one of the few services that also reaches\n`documentsApiUrl` — deposit-account supporting documents live in the documents\nLambda, not the internal API.\n\nManual bank accounts can be *bulk imported*: `BatchImportService.addNewMBAccounts`\nposts a CSV to `deposit_accounts/csv_upload`. Similarly pre-funded cards go to\n`payment/configs/csv_upload`. See xref:manage.adoc#bulk[bulk actions].\n\n== Routing-number validation\n\nBank routing numbers are validated with a status vocabulary rather than a boolean.\n\n`suppliers/enums/routing-number-validation-status.enum.ts`:\n\n[source,typescript]\n----\nexport enum RoutingNumberValidationStatus {\n  INCORRECT_ROUTING_NUM,\n  VERIFIED,\n  PENDING_VERIFICATION,\n  INSUFFICIENT_FOR_NATIONAL_PAYMENT,\n  INSUFFICIENT_FOR_INTERNATIONAL_PAYMENT,\n  NONE,\n}\n----\n\nEach status maps to a message and a severity via\n`RoutingNumberValidationStatusConfig` and\n`RoutingNumberValidationStatusType` (`success` / `danger` / `warning`).\n\nThe two `INSUFFICIENT_FOR_*` statuses are the interesting ones: the routing number\nis *valid* but the bank cannot receive that payment type. National and international\nare distinguished, so a supplier can be payable domestically and not by wire.\n\nA form validator enforces verification\n(`supplier-payment/routing-num.validator.ts`):\n\n[source,typescript]\n----\nexport function routingNumberStatus(value): ValidatorFn {\n  return (control) => {\n    const isVerified = value === 'VERIFIED';\n    return isVerified ? { routingNumNotVerified: { value: control.value } } : null;\n  };\n}\n----\n\n[WARNING]\n====\nThe logic reads inverted: the error `routingNumNotVerified` is returned when the\nstatus *is* `'VERIFIED'`. Either the error key is misnamed or the condition is\nwrong. It also compares against the string `'VERIFIED'` while the enum member is\nnumeric. Investigate against the calling template before relying on this validator.\n====\n\n== Plaid bank verification\n\n`supplier-payment-list/supplier-payment-plaid-information/supplier-plaid.service.ts`\n(+ mock) surfaces Plaid-derived bank information for a payment method. The app does\n*not* embed the Plaid Link SDK — the data arrives through the internal API. The\ntransaction-level Plaid data appears separately as an underwriting report tab\n(`ExternalReports.PLAID`). See\nxref:supplier-underwriting.adoc[underwriting].\n\n== Payment history\n\n`outgoing-payment-add/payment-history.service.ts` provides the history shown\nalongside a payment configuration — prior payments made using that method.\n\nBroader transaction views are separate tabs:\n`/suppliers/:key/transaction-summary`\n(`suppliers/supplier/supplier-transaction-summary/`) and\n`/suppliers/:key/transaction-details`\n(`supplier-transaction-details/`), with\n`suppliers/supplier/transaction-modal/` for individual transaction detail.\n\n== Store integration\n\nPayment configurations are the one area that uses the `actionId` idiom rather than\nper-operation flags. Actions in the `supplier` slice:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | Effect\n\n| `FETCH_SUPPLIER_PAYMENT_CONFIGS` / `_ERROR`\n| Loads into `SupplierModel.paymentConfigsModels`, keyed by `paymentConfigKey`.\n\n| `CREATE_SUPPLIER_PAYMENT_CONFIGS` / `_SUCCESS` / `_ERROR`\n| Create.\n\n| `UDPATE_SUPPLIER_PAYMENT_CONFIGS` (sic) / `UPDATE_…_SUCCESS` / `_ERROR`\n| Update. Note the misspelled request constant.\n\n| `DELETE_SUPPLIER_PAYMENT_CONFIGS` / `_SUCCESS` / `_ERROR`\n| Delete.\n\n| `PC_RESPONSE_SUCCESS` / `PC_RESPONSE_ERROR` / `PC_CLEAR_STATUS`\n| Write and clear entries in `paymentConfigsPending[actionId]` with\n  `PaymentConfigStatus.SUCCESS \\| ERROR`.\n|===\n\nThe caller generates an `actionId`, dispatches the operation, watches\n`paymentConfigsPending[actionId]`, then dispatches `PC_CLEAR_STATUS`. Forgetting the\nclear leaves a stale status that the next render of the same `actionId` will pick up.\n\n`selectPaymentMethods` is exported from `supplier.actions.ts` and is used by\n`AppComponent` to tell EVA whether the supplier has an EFS card\n(`paymentType === 'EFS'` or `pcardAvailable`) — see\nxref:platform:iframe-messaging.adoc[iframe messaging].\n\n== Related supplier actions\n\nMoney-moving operations that are not payment *configuration* live in the Actions\nmenu and are documented in\nxref:supplier-lifecycle.adoc[lifecycle actions and transactions]:\nadd receivable, add chargeback, add bad-debt recovery, add write-off, adjust the\nPayability fee, add a fund-transfer request, and the marketplace payment/statement\nadjustments.\n\nBatch-level payment release is xref:processes.adoc[processes and batches], and the\npre-release treasury check is xref:balance-approval.adoc[balance approval].\n\n== Gotchas\n\n* `MANAGE_LINKING_PAYMENT_GATEWAYS` is a permission in the enum; gateway linking on\n  the supplier's marketplace record is separate from payment configuration — see\n  xref:marketplaces.adoc[marketplaces].\n* `ADD_PAYMENT_GATEWAY` in the supplier reducer resets the slice to\n  `initialState` (keeping only the mutated `supplierModel`), discarding the\n  multi-supplier cache and every part-edit map.\n* `AutoPaymentService` builds its URL as `internalApiUrl + 'suppliers'` and then\n  concatenates `/$\\{supplierKey}/…`, so the base has no trailing slash while other\n  services in the same folder do include one. Match the file you are editing.\n"},{"component":"internal-admin","module":"domains","path":"domains:fees-and-reserves.adoc","title":"Fees, reserves and contracts","description":"How Payability's pricing is configured for a supplier — funding particulars, purchase and payment fees, per-marketplace reserves and minimum fees, and long-term contracts.","url":"https://docx.payability.com/internal-admin/domains/fees-and-reserves.html","navOrder":22,"source":"= Fees, reserves and contracts\n:description: How Payability's pricing is configured for a supplier — funding particulars, purchase and payment fees, per-marketplace reserves and minimum fees, and long-term contracts.\n\n== What it is\n\nFour related pricing concepts:\n\n*Funding particulars*::\nThe supplier-level funding record: facility limit, legal-signed flag, guarantor,\ncovenants, and the current fee schedule.\n\n*Fee schedules and tiers*::\nReusable pricing definitions maintained centrally under Manage, assigned to a\nsupplier or to a supplier-marketplace link.\n\n*Reserve*::\nThe percentage of incoming marketplace funds withheld — configured per marketplace\nlink, together with minimum-fee overrides and opt-in periods.\n\n*Long-term contracts*::\nThe signed agreement, templated per marketplace, with a history.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Supplier fees tab\n| `/suppliers/:supplierKey/fees/{purchase,payment,reserve,reserve/edit/:marketplaceKey}`\n  → `suppliers/supplier/supplier-fees/`\n\n| Fees service\n| `supplier-fees/service/supplier-fees.service.ts`\n\n| Fee models\n| `supplier-fees/model/{purchase-fee,payment-fee,reserve}.model.ts`\n\n| Funding particulars\n| `suppliers/services/funding-particulars.service.ts` (+ mock),\n  `suppliers/model/funding-particulars.model.ts`\n\n| Per-marketplace pricing\n| `suppliers/services/supplier-marketplace-funding.service.ts`,\n  `supplier-marketplace-min-fee-overrides.service.ts`,\n  `supplier-marketplace-opt-in-periods.service.ts`\n\n| Central fee settings\n| `/manage/fee-settings/{list,add,:id/edit}` →\n  `manage/fee-settings/` with `service/fee-settings.service.ts`\n\n| Contracts\n| `/suppliers/:supplierKey/contracts` →\n  `suppliers/supplier/supplier-contracts/`;\n  `/manage/contracts` → `manage/contracts/`;\n  `shared/services/long-term-contracts/long-term-contracts.service.ts`\n  (+ abstract + mock)\n|===\n\n== Funding particulars\n\n`FundingParticularsModel` is deliberately small:\n\n[source,typescript]\n----\nexport class FundingParticularsModel {\n  legalSigned: boolean;\n  fundPartStatus: string;\n  fundFacilityLimit: number;\n  guarantor: string;\n  supplierCovenants: string;\n}\n----\n\nIt is loaded as part of the supplier aggregate `forkJoin` (and is `null` for\n`ADVANCE_AUDITORS` users), and is one of the two models that\n`UPDATE_MODEL_PART_SUCCESS` merges saved card values into — so a card editing\n`fundFacilityLimit` updates both this and `SupplierDetailsModel`. See\nxref:suppliers.adoc#partial-editing[partial editing].\n\nEndpoints (`internalApiUrl`, via `SupplierFeesService`):\n\n[source]\n----\nGET suppliers/:key/funding_particulars                        supplier-level record\nPUT suppliers/:key/funding_particulars                        update\nGET suppliers/:key/funding_particulars/:marketplaceKey        per-marketplace record\nPUT suppliers/:key/funding_particulars/:marketplaceKey        { currentFeeScheduleKey }\nPUT suppliers/:key                                            { loadNetEarnings }\n----\n\nTwo things stand out:\n\n* Assigning a fee schedule to a marketplace is a `PUT` of a *single field*\n  (`currentFeeScheduleKey`) on the per-marketplace funding record. The schedule\n  itself is defined centrally.\n* `loadNetEarnings` — whether earnings are loaded gross or net — is a supplier-level\n  `PUT` on the supplier resource, even though it appears under Fees.\n\n== Per-marketplace pricing\n\nEverything below hangs off the *marketplace link* (`mpSupKey`), not the supplier.\nAll under `suppliers/:key/funding_particulars/:mpSupKey`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Managed by\n\n| `GET`/`PUT suppliers/:key/funding_particulars/:mpSupKey`\n| `SupplierMarketplaceFundingService` — the marketplace funding record\n  (`SupplierMarketplaceFundingModel`), including the reserve percentage.\n\n| `GET`/`PUT …/:mpSupKey/min_fee_overrides`\n| `SupplierMarketplaceMinFeeOverridesService` — overrides of the minimum fee for\n  this link (`SupplierMarketplaceMinFeeOverridesModel[]`).\n\n| `GET`/`PUT …/:mpSupKey/opt_in_periods`,\n  `PUT …/:mpSupKey/opt_in_periods/:mpSupOptInPeriodKey`\n| `SupplierMarketplaceOptInPeriodsService` — the date ranges in which this\n  marketplace is opted into funding\n  (`SupplierMarketplaceOptInPeriodModel[]`).\n|===\n\nIn the store these land in `SupplierModel` as three maps keyed by `mpSupKey`:\n`marketplaceFundingModels`, `marketplaceOptInPeriodModels`,\n`marketplaceMinFeeOverridesModels`.\n\nOpt-in rows have dedicated actions in the `supplier` slice —\n`ADD_OPT_IN_ROW`, `DELETE_OPT_IN_ROW` (each with `_SUCCESS` / `_ERROR`) and\n`ADD_OPT_IN_ROW_CLEAR` — with `addOptInProgress` / `deleteOptInProgress` flags for\nbutton state.\n\n[NOTE]\n====\n`SupplierDetailsModel.oldestOptIn` is the earliest opt-in date across all\nmarketplaces. It is a supplier-level rollup of link-level data, so it will not\nchange until the supplier record is re-fetched after an opt-in edit.\n====\n\n== The fees tab\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Child route | Screen\n\n| `fees/purchase`\n| `PurchaseFeesComponent` — fees charged when Payability purchases the receivable.\n\n| `fees/payment`\n| `PaymentFeesComponent` — fees charged on paying the supplier.\n\n| `fees/reserve`\n| `ReserveComponent` — the reserve percentage per marketplace.\n\n| `fees/reserve/edit/:marketplaceKey`\n| `EditMarketplaceComponent` — edit one marketplace's reserve.\n|===\n\nUnlike most supplier tabs, the fees routes carry *no* extra permission restriction —\nthe `fees` parent has no `RESTRICTED_*_CONFIG` spread. Access control for fee edits\nrests on the `INT_FEE_UPD` permission being enforced by the backend and on UI-level\n`ngxPermissions` directives inside the components.\n\n== Central fee settings\n\n`/manage/fee-settings` maintains the reusable schedules. `FeeSettingsService` on\n`internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET fee_schedule_types`\n| The available schedule types (drives the add/edit form).\n\n| `POST fee_schedules`\n| Create a schedule.\n\n| `PUT fee_schedules/:feeScheduleKey`\n| Rename / retype (`{ feeScheduleTypeCode, feeScheduleName }`).\n\n| `PUT fee_schedule_tier/:feeScheduleKey`\n| Replace the tier table (`{ tiers: [...] }`).\n\n| `PUT fee_schedules/:feeScheduleKey/extra`\n| Toggle free access (`{ freeAccess }`).\n\n| `DELETE fee_schedule_remove/:feeScheduleKey`\n| Delete.\n|===\n\n[CAUTION]\n====\nEndpoint naming is inconsistent — plural `fee_schedules` for CRUD, singular\n`fee_schedule_tier` for tiers, and a verb-style `fee_schedule_remove` for delete.\nDo not infer a URL; read the service.\n\nAlso note `editFeeSetting` replaces the *entire* tier array. A partial update is not\nsupported; read, modify, write the whole set.\n====\n\nAdjusting a fee on an individual transaction is a different operation — the\n*Adjust Payability Fee* action, see\nxref:supplier-lifecycle.adoc[lifecycle actions].\n\n[[contracts]]\n== Long-term contracts\n\n`LongTermContractsService` (in `shared/services/`, with an abstract base and a mock)\non `internalApiUrl` under `long_term_contract`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET long_term_contract/template/:marketplaceKey/standard`\n| The standard template for a marketplace.\n\n| `GET long_term_contract/template`\n| All templates.\n\n| `GET long_term_contract/template/:templateGroupKey`\n| One template group.\n\n| `POST long_term_contract/template/:marketplaceKey/create`\n| Create a template for a marketplace.\n\n| `GET long_term_contract/:supplierKey`\n| The supplier's current contracts.\n\n| `GET long_term_contract/:supplierKey/history`\n| Contract history.\n\n| `GET long_term_contract/:contractKey`\n| One contract.\n\n| `POST long_term_contract/:marketplaceSupplierKey/create`\n| Create a contract for a marketplace link.\n\n| `GET long_term_contract/:marketplaceSupplierKey/active`\n| The active contract for a link.\n|===\n\nNote the path-parameter ambiguity: `long_term_contract/:x` is used with a\n*supplier key*, a *contract key* and a *marketplace-supplier key* depending on the\nmethod. The distinction lives only in the method names.\n\nContracts are loaded as part of the supplier aggregate — both current and historical\n— which is why opening any supplier tab issues two contract calls (and none at all\nfor `ADVANCE_AUDITORS` users). See xref:suppliers.adoc[suppliers].\n\nScreens:\n\n* `/suppliers/:key/contracts` — `SupplierContractsComponent`, the per-supplier view.\n* `/manage/contracts` — `ContractsComponent`, template administration.\n* `suppliers/supplier/add-contract/` — the \"Add New Default Contract\" action from\n  the Actions menu, shown only when the supplier is active.\n\nThe `CONTRACTS__REFRESH` iframe message type exists so that an EVA-rendered contract\nview can ask the parent to refresh — see\nxref:platform:iframe-messaging.adoc[iframe messaging].\n\n== Business rules\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Rule | Detail\n\n| Pricing is layered\n| Central fee schedule → assigned to the supplier-marketplace funding record →\n  optionally overridden per link by minimum-fee overrides. The most specific level\n  wins, and the resolution happens in the backend.\n\n| Reserve lives on the link\n| There is no single supplier reserve percentage; each marketplace link has its own.\n  The advance calculator's `reserveForRepayments` is a *separate* concept — the\n  repayment reserve on an offer, not the marketplace funding reserve. See\n  xref:advances.adoc[advances].\n\n| Opt-in gates funding\n| A marketplace only contributes funding inside an opt-in period.\n\n| `legalSigned` and contracts are distinct\n| `FundingParticularsModel.legalSigned` is a flag on the funding record;\n  the long-term contract records are separate documents. Both must be right.\n|===\n\n== Gotchas\n\n* `MIN_TOTAL_FEE` / `MAX_TOTAL_FEE` in the advance calculator (5 % / 40 %) are\n  *advance* fee bounds, unrelated to fee schedules here.\n* `isMpReserveForPaymentEmpty` on the calculator state refers to the marketplace\n  reserve being zero — the one place the two reserve concepts touch.\n* `SupplierFeesService` builds `apiUrl` as `internalApiUrl + 'suppliers'` (no\n  trailing slash) and prefixes paths with `/`. The marketplace pricing services do\n  the same. Other supplier services use a trailing slash. Read before appending.\n"},{"component":"internal-admin","module":"domains","path":"domains:cards.adoc","title":"Card programmes","description":"The three card programmes — EFS, Marqeta and Airwallex — their statuses, issuing flows and the Airwallex onboarding state machine.","url":"https://docx.payability.com/internal-admin/domains/cards.html","navOrder":23,"source":"= Card programmes\n:description: The three card programmes — EFS, Marqeta and Airwallex — their statuses, issuing flows and the Airwallex onboarding state machine.\n\n== What it is\n\nPayability can give a supplier a *card* to spend against their balance. Three\nissuing programmes exist, added over time and all still live:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Programme | Status\n\n| *EFS*\n| The original prepaid card programme. Supplier flags `efsAvailable`,\n  `efsRebatePercent`, `efsLastCardReqTs`. Its own supplier tab.\n\n| *Marqeta*\n| Card issuing platform. Supplier field `marqetaStatuses { businessState, userState }`;\n  requires Marqeta's own KYC pass. Referred to in endpoints as `pcard`.\n\n| *Airwallex*\n| The newest programme, behind the `CARD_MANAGEMENT_AIRWALLEX` feature flag. Has a\n  full cardholder-onboarding state machine and terms acceptance.\n|===\n\nThe application never calls an issuer directly — everything goes through the internal\nAPI's card endpoints.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/suppliers/:supplierKey/card-transactions/efs`,\n  `…/card-transactions/marqeta`,\n  `…/card-transactions/marqeta/:marqetaKey`,\n  `…/card-transactions/airwallex`,\n  `…/card-transactions/airwallex/disputes`,\n  `…/card-transactions/airwallex/:awxKey`\n\n| Components\n| `suppliers/supplier/supplier-efs-account/`,\n  `supplier-marqeta-account/`,\n  `supplier-awx-account/` and its `awx-disputes/` (see <<awx-disputes>>),\n  `card-transactions-details/`,\n  `choose-card-type/`,\n  `supplier-payment/supplier-payment-list/` (the Airwallex panel) and its\n  `airwallex-card-list/`\n\n| Unified service\n| `suppliers/services/card-management/card-management.service.ts`\n  (`providedIn: 'root'`)\n\n| Programme services\n| `suppliers/services/efs.service.ts` (+ mock),\n  `supplier-marqeta-account/supplier-marqeta.service.ts`,\n  `supplier-awx-account/supplier-awx.service.ts`,\n  `suppliers/services/awx-disputes/awx-disputes.service.ts` (`providedIn: 'root'`)\n\n| Model\n| `suppliers/model/card.model.ts` (+ `card.model.spec.ts`),\n  `suppliers/model/awx-dispute.model.ts` (+ spec),\n  `supplier-awx-account/awx-transaction.model.ts`\n\n| Store\n| `availableCardProviders`, `supplierCards`, `airwallexStatus`,\n  `loadingAirwallexStatus`, `registeringAirwallex`, `issuingAirwallexCard`,\n  `airwallexCardError`, `airwallexCardholderBlock`, `airwallexOverrideRequested` in the\n  `supplier` slice\n|===\n\n`card.model.ts` is the best-designed model file in the repository — a discriminated\nunion plus pure mapper and predicate functions. Use it as the template for new\nmodels. See xref:architecture:data-modeling.adoc[data modelling].\n\n== The unified card model\n\n[source,typescript]\n----\nexport type CardProvider = 'marqeta' | 'airwallex';\n\nexport interface CardBase {\n  provider: CardProvider; cardId: string; status: string; lastFour?: string;\n}\nexport interface MarqetaCard extends CardBase {\n  provider: 'marqeta';\n  cardProductToken?; expiration?; pcardTypeIndicator?; shippingStatus?;\n}\nexport interface AirwallexCard extends CardBase {\n  provider: 'airwallex';\n  formFactor: 'VIRTUAL' | 'PHYSICAL'; cardNumber?: string | null; createdAt?: string;\n  // delivery data, populated for physical cards — see <<awx-shipping>>\n  shippingApplicable: boolean; deliveryAddress: string | null;\n  deliveryStatus: string | null; trackingCode: string | null; trackingLink: string | null;\n  // spend limits, only when the cards payload carries them — see <<awx-limits>>\n  maxXactAmountAtOnce?: number | null; maxXactAmountDaily?: number | null;\n}\nexport type SupplierCard = MarqetaCard | AirwallexCard;\n----\n\nNote that EFS is *not* in this union — it predates the unified model and is handled by\nits own service and tab.\n\n=== Status normalisation\n\nThe two providers use different status vocabularies for the same concepts. The model\nnormalises both.\n\nDisplay mapping (`STATUS_MAP` / `normalizeCardStatus`):\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Raw status | Display\n\n| `ACTIVE` | Active\n| `SUSPENDED` | Blocked\n| `INACTIVE` | Blocked\n| `UNACTIVATED` | Pending Activation\n| `PENDING` | Pending Activation\n| `TERMINATED` | Closed\n| `CLOSED` | Closed\n|===\n\nAnything unmapped passes through unchanged.\n\nSemantic predicates, which branch on the provider:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Predicate | Marqeta | Airwallex\n\n| `isCardActive`\n| `ACTIVE`\n| `ACTIVE`\n\n| `isCardBlocked`\n| `SUSPENDED`\n| `INACTIVE`\n\n| `isCardClosed`\n| `TERMINATED`\n| `CLOSED`\n\n| `isCardPendingActivation`\n| `UNACTIVATED`\n| `PENDING`\n|===\n\n[IMPORTANT]\n====\nAlways use the predicates, never a raw string comparison. `status === 'INACTIVE'`\nmeans \"blocked\" for Airwallex and nothing for Marqeta.\n====\n\n=== Id normalisation\n\nMarqeta identifies a card by `token`; Airwallex by `card_id`.\n`normalizeCardId(raw)` prefers `token`, falls back to `card_id`, then `''`.\n`mapMarqetaCard` / `mapAirwallexCard` / `mapSupplierCard` handle the rest, including\nderiving `lastFour` from `card_number` or `pan` when the backend does not send it.\n\n== Card management endpoints\n\n`CardManagementService` on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET suppliers/:key/card_providers`\n| Which providers are available for this supplier. Returns\n  `{ providers: CardProvider[] }`.\n\n| `GET suppliers/:key/cards`\n| List cards. Optionally filtered by provider — passed as a **request body on a\n  GET**, using `http.request('GET', url, { body })`.\n\n| `POST suppliers/:key/cards/newcard`\n| Issue a card (`NewCardRequest`: `provider`, `form_factor`,\n  `pcard_type_to_issue`, `recipient_address`, `expedite_card`, `created_by`).\n\n| `POST suppliers/:key/cards/block/:cardId`\n| Block.\n\n| `POST suppliers/:key/cards/activate/:cardId`\n| Activate.\n\n| `POST suppliers/:key/cards/remove/:cardId`\n| Remove/close.\n\n| `POST suppliers/:key/cards/reissue/:cardId`\n| Reissue.\n\n| `GET suppliers/:key/airwallex`\n| Airwallex cardholder status.\n\n| `PUT suppliers/:key/airwallex/cardholder`\n| Register the cardholder (`{ login_key }`).\n\n| `POST suppliers/:key/airwallex/newcard`\n| Issue an Airwallex card (`AirwallexIssueCardRequest`: `form_factor`,\n  `recipient_address`, `expedite_card`). See <<awx-shipping>>.\n\n| `POST suppliers/:key/airwallex/cards/:cardId/refresh`\n| Fetch the card from Airwallex once and persist the latest status and delivery data,\n  so the next cardholder-status read carries it.\n\n| `GET suppliers/:key/cards/:cardId/velocity/limits/awx`\n| One card's spend limits. See <<awx-limits>>.\n\n| `PUT suppliers/:key/cards/:cardId/velocity/limits/awx`\n| Replace one card's spend limits. Both amounts every time — see <<awx-limits>>.\n|===\n\n[CAUTION]\n====\n`getCards` sends a *body on a GET request*. That is unusual, and some proxies drop\nGET bodies. If provider filtering appears to be ignored in a deployed environment,\nthis is the first thing to check.\n====\n\nEvery method logs to `console.error` with context on failure and rethrows via\n`throwError(() => err)` — the modern factory form, unlike the `ErrorObservable.create`\nused in older services.\n\n== Airwallex onboarding state machine\n\nAirwallex is the only programme with a modelled onboarding progression.\n`deriveAirwallexState(status)` reduces the raw cardholder record to one of six states,\nin this precedence order:\n\n[source]\n----\n1. no status, or neither connected_account_id nor cardholder_id  → 'not_started'\n2. cards present (length > 0)                                    → 'active'\n3. cardholder_id present                                         → 'ready_for_card'\n4. connected_account_status === 'ACTIVE'                          → 'ready_for_cardholder'\n5. connected_account_status === 'REJECTED'                        → 'kyc_rejected'\n6. otherwise                                                     → 'kyc_pending'\n----\n\n[source]\n----\nnot_started ──► kyc_pending ──► ready_for_cardholder ──► ready_for_card ──► active\n                    │\n                    └────────► kyc_rejected\n----\n\n`AirwallexCardholderStatus` fields:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `airwallex_supplier_key`, `supplier_key`\n| Identifiers on both sides.\n\n| `connected_account_id`, `connected_account_status`\n| The Airwallex connected account.\n  Status: `ACTIVE` / `SUBMITTED` / `CREATED` / `REJECTED` / `SUSPENDED` / `null`.\n\n| `kyc_status`\n| `READY` / `PENDING` / other strings / `null`.\n\n| `cardholder_id`, `cardholder_type`\n| The cardholder once registered.\n\n| `cardholder_terms_accepted`, `cardholder_terms_response_ts`\n| Terms acceptance and when. Defaults to *not accepted* in the UI when unknown.\n\n| `rfi`\n| An Airwallex request for information: `{ rfi_status, rfi_id, rfi_type }`, all nullable.\n  Reported on its own row in the panel and deliberately *not* an input to\n  `deriveAirwallexState` — see <<awx-rfi>>.\n\n| `spending_account_id`, `funding_source_id`\n| Money plumbing.\n\n| `cards`\n| Onboarding-shaped card records (`AirwallexOnboardingCard`), mapped by\n  `mapAirwallexOnboardingCard`.\n|===\n\n=== Response normalisation\n\n`normalizeAirwallexStatus` handles three backend shapes, and this is where \"is this\nsupplier registered?\" is actually decided:\n\n[source,typescript]\n----\nif (!response) return null;\nconst record = response.db_record ?? response;     // may be wrapped in db_record\nif (!record || response.status === 'not_registered') return null;\nif (!record.connected_account_id && !record.cardholder_id && !record.airwallex_supplier_key)\n  return null;                                      // flat record, nothing set\nreturn {\n  ...record,\n  connected_account_status: record.connected_account_status\n                          ?? response.connected_account_status ?? null,\n  rfi: record.rfi ?? response.rfi ?? null,\n  cards: record.cards ?? response.cards ?? [],\n};\n----\n\nSo `airwallexStatus === null` in the store means *not registered*, not *not loaded* —\n`loadingAirwallexStatus` is the loading flag.\n\n`connected_account_status`, `rfi` and `cards` are named explicitly rather than left to\nthe spread because the backend puts them either on `db_record` or beside it, depending\non the shape. A record written before the backend added `rfi` normalises to `null`, which\nis how the panel knows to omit the row entirely.\n\n[#awx-rfi]\n=== Requests for information\n\nAirwallex can raise an RFI against a connected account, and the endpoint reports it as\n`{ rfi_status, rfi_id, rfi_type }`. The panel shows it as a row under the cardholder\nterms: a badge with the status, plus the type and the RFI id when one is outstanding.\n`NO_RFI` still renders, so an operator can tell \"nothing outstanding\" apart from \"this\nrecord is too old to say\".\n\nThe status vocabulary belongs to the backend — `NO_RFI` and `ANSWERED` are the confirmed\nvalues — so `AirwallexRfiStatus` stays an open union and both display helpers degrade\nrather than break:\n\n* `airwallexRfiStatusLabel` maps the known codes to prose and otherwise strips the\n  underscores, so a new `ACTION_REQUIRED` reads as `ACTION REQUIRED` instead of\n  `No_rfi`-style mangling from the `titlecase` pipe.\n* `rfiStatusClass` colours `ANSWERED` amber and everything else, known or not, neutral\n  grey. Guessing a colour for an unrecognised code risks telling the operator the\n  opposite of what it means.\n\nAn outstanding RFI does not change the onboarding state — `deriveAirwallexState` still\nkeys off the connected account, cardholder and cards alone.\n\n[#awx-shipping]\n== Airwallex physical cards and shipping\n\nA virtual Airwallex card is active the moment it is created. A *physical* card ships\nto an address and is created `PENDING`, to be activated on delivery.\n\n=== Ordering a physical card\n\nIssuing from the Airwallex panel on the supplier payment list branches on the selected\nform factor. `VIRTUAL` dispatches straight away; `PHYSICAL` first opens\n`ChooseCardTypeComponent` in *shipping-only* mode (`data.shippingOnly === true`), which\nskips provider and card-type selection and opens directly on the address form and the\n`Expedite Shipping` checkbox. Cancelling the dialog issues nothing.\n\nThe address is pre-filled from the supplier's main owner, the same source Marqeta has\nalways used, and the operator can edit it before ordering.\n\n`CardManagementService.issueAirwallexCard` owns the payload rules:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Rule | Why\n\n| `recipient_address` only for `PHYSICAL`\n| A virtual card ships nowhere; the backend ignores an address on one.\n\n| Empty `address2` is stripped\n| The backend expects the field absent rather than blank.\n\n| `expedite_card` coerced with `=== true`\n| The backend rejects the *string* `\"false\"` and would not treat it as standard mail.\n|===\n\nBackend behaviour worth knowing:\n\n* The address is **request-driven**. A submitted address ships and is written back to\n  the `login` row. Omitting it falls back to the supplier's business address, with no\n  write-back.\n* Physical issuance is **fail-closed**: an incomplete resolved address returns `400`\n  naming the missing fields and creates no card. There is no fallback to a stale\n  cardholder address.\n* Express shipping requires a phone number on the login or supplier record. Without\n  one the order is rejected with `400` before Airwallex is called.\n* The duplicate guard is per form factor — an `ACTIVE` or `PENDING` card of the same\n  form factor blocks a second one, and the other form factor is unaffected.\n\n=== Reading delivery and tracking\n\nDelivery data rides on the card record itself — every card in the `cards` array of\n`GET suppliers/:key/airwallex` carries `shipping_applicable`, `delivery_address`,\n`delivery_status`, `tracking_code` and `tracking_link`. There is no separate shipping\nread, so the expanded card row needs no extra request.\n\n`mapAirwallexOnboardingCard` and `mapAirwallexCard` both fold these onto\n`AirwallexCard`, flattening `delivery_address` into a single display string. Note that\nthe returned address uses Airwallex's own shape (`line1` / `postcode`), which is *not*\nthe shape sent on create (`address1` / `zip`).\n\n`AirwallexCardListComponent` renders delivery status, the delivery address, the\ntracking code and a tracking link in the expanded row of a physical card. Values the\nbackend has not filled in yet render as an em dash. Virtual rows show none of this and\nhave no refresh action.\n\nThe stored values only move when something asks Airwallex, so `Refresh status` calls\nthe refresh endpoint and then emits `changed`, which re-runs `FetchAirwallexStatus` and\nbrings back both the refreshed card status and its delivery fields.\n\n== Store integration\n\nActions in the `supplier` slice:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | State effect\n\n| `FETCH_CARD_PROVIDERS` / `_SUCCESS` / `_ERROR`\n| `availableCardProviders` (initially `null`).\n\n| `FETCH_SUPPLIER_CARDS` / `_SUCCESS` / `_ERROR`\n| `supplierCards` (initially `null`).\n\n| `FETCH_AIRWALLEX_STATUS` / `_SUCCESS` / `_ERROR`\n| `airwallexStatus` + `loadingAirwallexStatus`.\n\n| `REGISTER_AIRWALLEX_CARDHOLDER` / `_SUCCESS` / `_ERROR`\n| `registeringAirwallex` and `airwallexCardholderBlock`; success also replaces\n  `airwallexStatus`. The payload carries\n  `{ supplierKey, loginKey, overwriteMissingDocuments? }` — see\n  <<awx-partial-submit,submitting with the minimum documents>>.\n\n| `ISSUE_AIRWALLEX_CARD` / `_SUCCESS` / `_ERROR`\n| `issuingAirwallexCard` and `airwallexCardError`. The payload carries\n  `{ supplierKey, formFactor, recipientAddress?, expediteCard? }`. The effect chases\n  success with `FetchAirwallexStatus`, so the new card appears without the caller\n  re-fetching.\n|===\n\n`null` vs `[]` matters: `null` means \"never loaded\", `[]` means \"loaded, none\".\n\n[#awx-errors]\n=== Surfacing Airwallex failures\n\nEvery Airwallex operation fails closed and the backend message names the cause, so no\nAWX call is allowed to fail silently. `extractAirwallexError(error, fallback)` in\n`supplier.reducers.ts` is the one place that digs the message out, preferring\n`error.error.msg`, then `error.error.message`, then a plain-string body, then\n`error.message`, and only then the caller's fallback.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Failure | How the operator sees it\n\n| Cardholder registration (`PUT …/airwallex/cardholder`)\n| `REGISTER_AIRWALLEX_CARDHOLDER_ERROR` stores the message in\n  `airwallexCardholderBlock`, which is what `selectAirwallexCardholderError` reads. The\n  panel renders it in a persistent `awx-cardholder-error` alert, because a KYC failure\n  enumerates every missing field (`supplier.tax_id (EIN)`, `login[…].birth_date`, and so\n  on) and the operator needs it on screen long enough to act on. It *also* opens a dialog\n  — always, whether or not a resubmit is on offer — so this is the one AWX failure that\n  does not toast; see <<awx-partial-submit>>. Retrying, or re-reading the status, clears\n  it.\n\n| Card issuance (`POST …/airwallex/newcard`)\n| `ISSUE_AIRWALLEX_CARD_ERROR` stores the message in `airwallexCardError`; the panel\n  subscribes to `selectAirwallexCardError` and toasts. `ISSUE_AIRWALLEX_CARD` clears it.\n\n| Card operations (activate, block/unblock, replace, remove, refresh)\n| `AirwallexCardListComponent` calls the service directly and toasts the backend\n  message from the `subscribe` error handler.\n|===\n\n[#awx-partial-submit]\n=== Submitting with the minimum documents\n\nBefore it creates a Connected Account the backend checks the supplier's compliance\ndocuments and refuses the submit if any required one is missing. Some of those refusals\ncan be forced: the same `PUT` is repeated with `overwrite_missing_documents: true`, and\nthe backend skips the *missing* files and submits what it has.\n\n**Whether a refusal can be forced is the backend's decision, and it states it outright.**\nThe 400 body carries the verdict alongside the prose:\n\n[source,json]\n----\n{\n  \"message\": \"Cannot create Airwallex Connected Account for supplier <key>: …\",\n  \"can_overwrite\": true,\n  \"missing_documents\": [\"ein_letter\", \"proof_of_business\"]\n}\n----\n\nBoth extra fields are optional. `can_overwrite` is the gate — the resubmit is offered\n*if and only if* it is the literal boolean `true`. Absent, `false`, or a truthy-but-not-true\nvalue such as `'true'` or `1` all mean no resubmit, because an unrecognised shape must fail\nclosed rather than send the operator on a round trip the backend has already ruled out.\n`missing_documents` names what the supplier still owes; `airwallexDocumentLabel` in\n`card.model.ts` maps the known codes to readable labels and tidies up an unknown one rather\nthan dropping it, since the operator has to chase the file either way.\n\n`resolveCardholderBlock` turns that into an `AirwallexSubmitBlock` of the verbatim\n`message`, an `overridable` boolean and a `missingDocuments` list.\n`SupplierPaymentListComponent` then opens `ConfirmDialogComponent` for *every* refusal,\nwith the message in its `alertMessage` box and the documents in its `details` list:\n\n* **Overridable** — *Resubmit anyways* plus *Close*. Confirming re-dispatches\n  `RegisterAirwallexCardholder` with `overwriteMissingDocuments: true`.\n* **Not overridable** — `isCloseOnly`, so a single *Close* button. The operator has to fix\n  the cause at the source.\n\nEither way no toast is raised: it would stack on top of the modal, and the inline\n`awx-cardholder-error` alert already keeps the reason on screen after the dialog closes.\n\nTwo guards matter:\n\n* The flag is only ever sent as a real boolean. The backend reads it as\n  `… is True`, so `'true'` or `1` would silently mean false.\n* `airwallexOverrideRequested` records that the in-flight request already carried the\n  override, and `resolveCardholderBlock` forces `overridable` to `false` when it did, even\n  if the backend still says `can_overwrite: true`. A failed override never offers another\n  one, so the operator pays at most one wasted round trip per refusal. Re-reading the\n  status clears the block and resets the guard.\n\n== Airwallex rebate expiry\n\nThe Airwallex transactions page lets an operator make the supplier's\n`efsRebatePercent` temporary. For a positive rebate, the form asks whether the\nrate has an expiration date. Selecting *Yes* reveals a required whole-number\nduration from 1 through 365 days; saving *No* hides and clears it. A zero, empty,\nor null rebate hides the expiration controls and clears any pending duration\nbecause there is no positive rebate to time-box.\n\nOn initial load, the rebate percentage comes from `efsRebatePercent` in supplier\ndetails. When that response also contains `monthlyRebatePromo`, the form selects\n*Yes*, initializes the duration from its `daysLeft`, and displays the\npromotion's `startTs` and `expiryTs`.\n\nBoth variants use the existing partial supplier update:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Selection | `PUT suppliers/:key` payload\n\n| No\n| `efsRebatePercent` plus `monthlyRebatePromoDays: 0`, which ends the\n  promotion.\n\n| Yes\n| `efsRebatePercent` plus numeric `monthlyRebatePromoDays`.\n|===\n\nIMPORTANT: The client sends zero only when the operator explicitly saves *No*.\nAn ordinary rebate save without an expiration change omits\n`monthlyRebatePromoDays`, leaving any running promotion untouched.\n\nThe form blocks empty, fractional, negative, zero, and greater-than-365\ndurations before calling the API. The backend owns the expiry calculation and\nenforcement; no browser timer is involved. Resending an unchanged duration\nkeeps the promotion's original start date rather than restarting it from today.\n\n== EFS\n\n`EfsService` on `internalApiUrl`, with two URL families:\n\n[source]\n----\nGET/POST suppliers/:key/efs/:resource\nGET/POST suppliers/:key/pcard/:resource\nGET      suppliers/:key/efs/get_company_report\nGET      suppliers/:key/pcard/get_general_report_v2\n----\n\n`efs/*` is the EFS programme; `pcard/*` is the Marqeta/prepaid-card programme —\n*the same service covers both*, which is easy to misread. `supplier-efs-account/`\nrenders the EFS tab.\n\nEFS card requests are tracked on the supplier: `REQUEST_EFS_CARD` action,\n`SupplierModel.efsCardRequested`, and `efsLastCardReqTs` on the details model.\n`efsRebatePercent` is the supplier's rebate share.\n\n`selectPaymentMethods` treats a payment config with `paymentType === 'EFS'` (or\n`pcardAvailable`) as \"has a seller card\" when reporting menu conditions to EVA — see\nxref:platform:iframe-messaging.adoc[iframe messaging].\n\n== Marqeta\n\n`SupplierMarqetaService` on `internalApiUrl`:\n\n[source]\n----\nGET suppliers/:key/pcard/get_general_report_v2            transactions\nGET suppliers/:key/pcard/get_general_report_v2/statuses   status vocabulary\nGET suppliers/:key/pcard/:transactionId/transaction_details\n----\n\nScreens: `supplier-marqeta-account/` (the list, at\n`card-transactions/marqeta`) and `card-transactions-details/` (one transaction, at\n`card-transactions/marqeta/:marqetaKey`).\n\nMarqeta KYC lives with underwriting:\n`suppliers/:key/marqeta_kyc_compliances` and `…/owners` (GET and PUT) in\n`SupplierUnderwritingService`. Both the business and every owner must pass before a\ncard can be issued. See xref:supplier-underwriting.adoc[underwriting].\n\n`marqetaStatuses.businessState` and `.userState` on the supplier record are the\nissuer-side states.\n\n== Choosing a card type\n\n`suppliers/supplier/choose-card-type/` is the dialog shown when more than one\nprovider is available for a supplier — driven by `card_providers`. With a single\nprovider it is skipped.\n\nIt has two opt-in modes for callers that already know what they are ordering, both set\nthrough `MAT_DIALOG_DATA`: `replacementMode` for replacing a physical Marqeta card, and\n`shippingOnly` for ordering a physical Airwallex card (see <<awx-shipping>>). Both open\ndirectly on the shipping form.\n\n== JIT simulation\n\nCard spend uses just-in-time funding. `PaymentConfigService` exposes five simulation\nendpoints (`jit/simulate/{pending,rejected,charge,cleared,refund}`) for exercising\nthat flow without real transactions. See\nxref:supplier-payments.adoc[supplier payments].\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| `pcard` means Marqeta\n| The endpoint prefix does not say Marqeta anywhere.\n\n| EFS is outside the unified model\n| `SupplierCard` covers only Marqeta and Airwallex. Do not assume a card list\n  includes EFS.\n\n| Airwallex is flag-gated\n| `CARD_MANAGEMENT_AIRWALLEX`. With the flag off the provider is absent from the UI\n  even if the backend returns it.\n\n| Terms default to *not accepted*\n| The cardholder-terms view treats unknown as not accepted, deliberately.\n\n| GET with a body\n| `getCards` filters by provider using a GET body.\n\n| Two shapes for one address\n| Physical issuance *sends* `address1`/`zip`; the card record *returns*\n  `line1`/`postcode`. The card mappers bridge them.\n\n| `expedite_card` must be a real boolean\n| The string `\"false\"` is rejected rather than read as standard mail, which is why the\n  service coerces with `=== true`.\n\n| Express needs a phone number\n| `expedite_card: true` without a phone on the login or supplier record is a `400`\n  before Airwallex is called.\n\n| Delivery data is stored, not live\n| The card record serves persisted values and never calls Airwallex. Use `…/refresh`\n  when the data looks stale.\n\n| Limit writes replace both amounts\n| A limits `PUT` that omits one amount *clears* it. The client always sends the pair.\n\n| No AWX limit is not \"unlimited\"\n| Both amounts unset means the backend pushes its own per-transaction default to\n  Airwallex, so the UI names that default instead of showing an em dash.\n\n| The disputes route must stay above `:awxKey`\n| `card-transactions/airwallex/disputes` is registered before\n  `card-transactions/airwallex/:awxKey`, which would otherwise match `disputes` as a\n  transaction key and render the transaction-details screen. See <<awx-disputes>>.\n\n| The disputes list is supplier-wide\n| There is no card filter — the backend list endpoint is supplier-scoped. The\n  Transaction column is what ties a row back to card activity.\n\n| The Airwallex tab is not in the supplier tab bar\n| `supplier-box-header-old` only adds `card-transactions/marqeta` (\"Seller Card\").\n  Operators reach Airwallex card transactions — and therefore disputes — through the\n  EVA supplier menu or by URL. See <<awx-disputes>>.\n|===\n\n[#awx-limits]\n== Airwallex per-card spend limits\n\nAirwallex enforces card spend limits itself, and configures them *per card*. Two amounts\nexist — `max_xact_amount_at_once` (per transaction) and `max_xact_amount_daily` — and\neither may be `null`. There is no hourly transaction-count limit for Airwallex, and no\nsupplier-level Airwallex limits endpoint: both were removed with GAR-2347. Marqeta and EFS\nkeep their own supplier-level velocity forms, hourly count and foreign-fraud override\nincluded.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Rule | Behaviour\n\n| Full replace\n| `PUT` writes both amounts. The backend cannot tell an omitted field from an explicit\n  `null` and clears the column either way, so `updateAirwallexCardLimits` always sends the\n  pair. An emptied input is sent as `null`.\n\n| `null` means \"backend default\"\n| With both amounts unset the backend pushes a single per-transaction fallback to\n  Airwallex (`AIRWALLEX_DEFAULT_PER_TRANSACTION_LIMIT` mirrors it for display). `0` is a\n  legal limit and is *not* the same as unset, which is why the mappers normalise with `??`\n  and never `||`.\n\n| Closed cards are read-only\n| The backend rejects a limits write on a `CLOSED` card with a `400`; every other status\n  accepts one. `canEditLimits` delegates to the existing `canModify` predicate rather than\n  restating the rule.\n\n| Effective immediately, spend is not\n| New limits apply in Airwallex at once, but the amount already spent today is not reset.\n  A daily limit below it blocks the card until the next day — the confirmation dialog says\n  so before the write.\n|===\n\n`AirwallexCardListComponent` reads limits when a row is expanded, so cards nobody opens\ncost no requests. If the cards payload already carries the two amounts they are used\ndirectly; otherwise one `GET` per opened card fills them in. The values are cached in the\ncomponent by `cardId` and *not* written onto the card objects, because\n`SupplierPaymentListComponent.airwallexCards` is a getter that re-maps the array on every\nchange-detection cycle.\n\n[#awx-disputes]\n== Airwallex transaction disputes\n\nAirwallex offers no screen for card disputes, so Internal Admin owns the whole workflow:\nraise a dispute against a card transaction event, attach evidence to it, submit it to the\ncard scheme, cancel it while that is still allowed, and follow the outcome. Added by\nGAR-2368 on top of the internal-admin endpoints GAR-2329 shipped.\n\nA dispute record is *UI and reporting only*. Winning one credits the supplier through a\nseparate `DISPUTE_CREDIT` clearing event booked by the backend, so nothing on this page\nmoves a balance — the create dialog says so on screen.\n\n=== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Route\n| `card-transactions/airwallex/disputes` → `AwxDisputesPageComponent`, a child of\n  `:supplierKey` in `suppliers/suppliers-routing.routes.ts`, spreading\n  `RESTRICTED_UW_ACCESS_CONFIG`\n\n| Components\n| `supplier-awx-account/awx-disputes/`: `awx-disputes-page.component` (routed shell),\n  `awx-dispute-list.component` (table and every action),\n  `awx-dispute-create-dialog.component`, `awx-dispute-evidence-dialog.component`,\n  and a shared `awx-disputes.component.scss`\n\n| Service\n| `suppliers/services/awx-disputes/awx-disputes.service.ts`\n\n| Model\n| `suppliers/model/awx-dispute.model.ts` — `AwxDispute`, `AwxDisputeStatus`,\n  `CreateAwxDisputeRequest`, `AwxDisputeFilters`, the reason and status lists, the state\n  predicates and `validateEvidenceFile`\n\n| Entry points\n| A *Disputes* button beside Export Current / Export All on the Airwallex card\n  transactions tab, and a per-row *Dispute* column in that tab's grid\n|===\n\nThere is no NgRx slice. The page holds its state in the components, as the rest of the\nAirwallex card transactions tab does.\n\n`AwxDisputesPageComponent` resolves the supplier key from `selectSupplierDetails` and\npasses it down as an `@Input`, so it arrives *after* first render.\n`AwxDisputeListComponent` therefore loads on `ngOnChanges` as well as `ngOnInit`, and\nnever fetches on a falsy key — a fetch in `ngOnInit` alone would put `undefined` in the\nURL.\n\n=== Lifecycle and affordances\n\n[source]\n----\nDRAFT ──► SUBMITTED ──► IN_PROGRESS ──► WON | LOST | REJECTED | EXPIRED\n  │            │\n  └────────────┴──────► CANCELED\n----\n\n`AwxDisputeStatus` is a string-literal union over exactly those eight values.\nAirwallex enforces the rules server-side and rejects a disallowed action with a `400`;\nthe UI derives the affordances from status so an operator is never offered a button that\ncannot succeed.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Predicate | True for | Drives\n\n| `canAddEvidence`\n| `DRAFT`\n| The *Add evidence* row action.\n\n| `canSubmit`\n| `DRAFT`\n| The *Submit* row action.\n\n| `canCancel`\n| `DRAFT`, `SUBMITTED`\n| The *Cancel* row action. Airwallex blocks cancellation once the scheme has the case.\n\n| `isTerminal`\n| `WON`, `LOST`, `REJECTED`, `EXPIRED`, `CANCELED`\n| Nothing actionable. `IN_PROGRESS` also offers no actions, and is deliberately *not*\n  terminal.\n|===\n\n[IMPORTANT]\n====\nAs with card status, always call the predicates rather than comparing the status string\nin a template. The affordance rules live in the model so they are tested in one place.\n====\n\nThe list renders `detailed_status` beside the status pill when it differs from `status`,\nso the Airwallex sub-state is visible without a column of its own. Submit and Cancel each\nrun confirm → call → toast → reload, with a per-row `busyDisputeId` disabling that row\nwhile a call is in flight. Filtering by status is client-side: the list is small enough to\nfetch whole, and there is no pagination.\n\n=== Endpoints\n\n`AwxDisputesService` on `internalApiUrl`, which already ends in `internal/`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET suppliers/:key/airwallex/disputes`\n| List. The backend has already followed Airwallex cursor pagination and returns a flat\n  array; the service coerces a non-array body to `[]`. Takes an optional\n  `AwxDisputeFilters` object, with empty values dropped from the `HttpParams`.\n\n| `POST suppliers/:key/airwallex/disputes`\n| Create. Requires `card_transaction_event_id` and `reason`; `amount`, `notes`, `reference`\n  and `additional_info` are optional.\n\n| `POST suppliers/:key/airwallex/disputes/:disputeId/evidence`\n| Attach evidence. `multipart/form-data`, file under `file`, *one file per request*. The\n  service sets no `Content-Type` so the browser writes the multipart boundary.\n\n| `POST suppliers/:key/airwallex/disputes/:disputeId/submit`\n| Submit to the scheme.\n\n| `POST suppliers/:key/airwallex/disputes/:disputeId/cancel`\n| Cancel.\n|===\n\nEvery one of them returns a dispute object. The fields are a *snake_case pass-through* of\nthe Airwallex shape — the backend does not reshape it, so the model does not camel-case\nit either, matching `AwxTransaction` on the same tab. Treat the field set as open.\n\n[WARNING]\n====\nThe event id has two names. Create takes it as `card_transaction_event_id`; the dispute\nobject that comes back — and the transaction report the id is read from — both call the\nsame value `transaction_event_id`. `CreateAwxDisputeRequest` carries the request name and\n`AwxDispute` the response name. Normalising them in the UI would hide a backend fact, so\nthe mismatch is deliberate; copying a field name from the wrong one produces a `400`.\n====\n\n[NOTE]\n====\nUnlike `CardManagementService`, this service neither logs nor wraps errors. The backend\nforwards the Airwallex message prefixed `Airwallex error: …`, and every caller renders\nthat `message` verbatim — inline in a dialog, or as a toast for a row action. It names the\ncause better than anything this layer could substitute.\n====\n\n=== Evidence rules\n\n`validateEvidenceFile` mirrors the limits the backend enforces so the operator hears about\na bad file before committing to a 20 MB upload. It returns an operator-facing message, or\n`null` when the file is acceptable. The backend remains the source of truth.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Rule | Detail\n\n| Allowed extensions\n| `pdf`, `jpg`, `jpeg`, `png`, `doc`, `docx`, `xls`, `xlsx`, `csv`, `mp4`, `heic`\n  (`AWX_EVIDENCE_ALLOWED_EXTENSIONS`), read off the last `.` of the name and lowercased.\n\n| `.txt` is called out separately\n| Airwallex rejects it specifically, and \"not an allowed type\" reads as a mistake the\n  operator cannot act on. It gets its own message.\n\n| Maximum size\n| 20 MB (`AWX_EVIDENCE_MAX_BYTES`).\n\n| Maximum file name\n| 50 characters (`AWX_EVIDENCE_MAX_FILENAME_LENGTH`).\n|===\n\nThe dialog validates on selection, not on upload, and a failure disables the upload button\n— the request is never sent. It binds `app-input-file`, which writes a `FileInputModel`\nrather than a raw `File`, so the dialog reads `control.value?.files?.[0]`.\n\n=== Creating from a transaction row\n\nA dispute is keyed on the transaction *event*, not the transaction, so\n`AwxTransaction` and `AwxTransactionMapper.map` carry `transaction_event_id`, sitting\nbeside `transaction_id` where it belongs rather than at the end of the constructor's\ntwenty positional parameters. That is safe only because `AwxTransactionMapper.map` is the\nsingle construction site in `src/`; add another and the argument order becomes a trap.\n\nThe grid's *Dispute* column sits after the Transaction ID column — anywhere after the\npinned `ROW_COUNT_COLUMN_DEF`, which has to stay leftmost. It renders a link through\n`cellRenderer` and handles the click in `onCellClicked`, destructuring `({ data })`\nbecause an action column needs the row rather than the cell value. It\nnavigates to the disputes page with `createFor` (the event id), `transactionId` and\n`amount` on the query string; the list component opens the create dialog from those\nparameters and then clears them with `replaceUrl`, so reloading or navigating back does\nnot reopen it.\n\nThe create dialog prefills and *disables* the event-id control — the operator can confirm\nwhich event they are disputing but cannot retype it — and reads the value from the\ninjected dialog data, since Angular omits disabled controls from `form.value`. Optional\nfields are omitted from the payload when empty rather than sent blank: an absent `amount`\ndisputes the full transaction, which is not the same request as `0`. `reference` is capped\nat 20 characters (`AWX_DISPUTE_REFERENCE_MAX_LENGTH`) with a counter and a field-level\nerror. On a backend rejection the dialog stays open with the typed input intact.\n\n[CAUTION]\n====\nThe Dispute cell renders no link, and the click handler no-ops, when the row has no\n`transaction_event_id`. That field comes from\n`POST suppliers/:key/pcard/get_general_report_awx`, which did not always return it — so a\npartial backend rollout leaves the column empty rather than producing an invalid create.\nAn empty Dispute column across every row is the symptom to check first.\n====\n\n=== Scope and reach\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Decision | Why\n\n| Supplier-wide, not per card\n| GAR-2368 asked for supplier *and card* scoping, but the backend list endpoint is\n  supplier-scoped and exposes no `card_id` filter. Accepted on 2026-09-15: dispute volumes\n  are low, and the Transaction column already identifies the card activity behind each row.\n  The service passes a filter object through, so narrowing later costs only a parameter.\n\n| No feature flag, no new permission\n| The page is additive, its actions are guarded by dispute state on the backend, and it is\n  reachable only from the Airwallex card transactions tab. Access is inherited from the\n  route's `RESTRICTED_UW_ACCESS_CONFIG`.\n\n| No new environment variable\n| The service reads the existing `internalApiUrl`.\n|===\n\nDiscoverability is inherited too, and it is thinner than it looks: the supplier tab bar\nhas no Airwallex entry at all — `supplier-box-header-old` only adds\n`card-transactions/marqeta` as \"Seller Card\". Operators reach the Airwallex tab through\nthe EVA supplier menu, which `app.component.ts` enables via `isSellerCardAwx` when the\nsupplier has a `PCARD-AWX` payment type, or by typing the URL. The disputes page is\nexactly as discoverable as the tab it hangs off, no more. This predates GAR-2368. See\nxref:platform:iframe-messaging.adoc[iframe messaging] for the menu-conditions message.\n"},{"component":"internal-admin","module":"domains","path":"domains:supplier-lifecycle.adoc","title":"Lifecycle actions and transactions","description":"Changing a supplier's standing — hide, suspend, lock, cancel, write off, delete, win back — and the global Actions menu money operations.","url":"https://docx.payability.com/internal-admin/domains/supplier-lifecycle.html","navOrder":24,"source":"= Lifecycle actions and transactions\n:description: Changing a supplier's standing — hide, suspend, lock, cancel, write off, delete, win back — and the global Actions menu money operations.\n\n== What it is\n\nTwo families of operator action:\n\n*Lifecycle* — changes to the supplier's standing: hidden, suspended, locked,\ncancelled, written off, deleted, or won back.\n\n*Transactions* — money and data operations available from the global *Actions* menu\nregardless of which tab you are on: earnings and remittance loads, chargebacks,\nrecoveries, receivables, write-offs, statement and payment adjustments, and fund\ntransfer requests.\n\nBoth are implemented as dialogs under `suppliers/supplier/`, each with its own\nservice, following a shared pattern established by\n`suppliers/supplier/base-supplier-action/`.\n\n== Status vocabulary\n\n`shared/consts/supplier-status.enum.ts`:\n\n[source,typescript]\n----\nexport enum SUPPLIER_STATUS {\n  ACTIVE             = 'Active',\n  PENDING_ACTIVATION = 'Pending Activation',\n  PENDING            = 'Pending',\n  SUSPENDED          = 'Suspended',\n}\n----\n\n`SupplierStatusModel` is a much richer rollup than the supplier record itself, and is\nwhat the header badges read:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `supplierStatus`\n| `SUPPLIER_STATUS`.\n\n| `hasMarketplaces`, `legalSigned`, `instantAccess`, `generalInfoUpdated`\n| Readiness gates.\n\n| `fundPartStatus`\n| Funding-particulars status (`Not Selected` / `Selected` / `Sent` / `Rejected` /\n  `Declined` / `Approved`).\n\n| `ascApiStatus`\n| Amazon Seller Central API health.\n\n| `pmtCfgStatus`, `pmtCfgProvided`, `hasPaymentHistory`\n| Payment-configuration readiness.\n\n| `nextPayment`, `enrollmentStart`\n| Schedule dates.\n\n| `hasReceivablePurchase`\n| Whether receivables have been purchased.\n\n| `efsAccount`\n| Whether an EFS card account exists.\n\n| `hasAutoTransferEnabled`, `autoTransferMinimumBalanceToStay`,\n  `hasForwardPaymentEnabled`\n| Automatic transfer configuration.\n\n| `supAdvStatus`, `supAdvLiability`\n| Advance status (`Active` / `Offer Pending` / `Closed` / `None`) and outstanding\n  liability.\n|===\n\n`SupplierStatusesService` maps each status vocabulary to a Bootstrap badge class,\nwith `'badge badge-light'` as the fallback for unknown values. Six dictionaries:\n`supFundPartStatusDictionary`, `mpFundPartStatusDictionary`,\n`payConfigStatusDictionary`, `supplierStatusDictionary`,\n`supplierCancelationStatusDictionary` (`none` / `pending` / `canceled`) and\n`advanceStatusDictionary`.\n\n[NOTE]\n====\nThese dictionaries are keyed on the backend's *display strings*, not codes. A wording\nchange upstream silently degrades every badge to the neutral fallback rather than\nthrowing.\n====\n\n== Lifecycle actions\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Action | Implementation | Notes\n\n| Hide / unhide\n| `toggle-hide-supplier/`;\n  `TOGGLE_HIDE_SUPPLIER` in the `supplier` slice\n| Hides the supplier from lists. The reducer *flips*\n  `supplierDetailsModel.hide` locally on success rather than re-reading it.\n\n| Suspend\n| `suspend-supplier/`; `TOGGLE_SUSPEND_SUPPLIER`\n| Sets status to `Suspended`. The reducer records only progress/error — the model is\n  refreshed by re-fetch.\n\n| Lock / unlock\n| `supplier-unlock/`; `TOGGLE_LOCK_SUPPLIER`;\n  `supplierLocked` on the details model\n| Actions and effects exist but the reducer has *no cases* for them, so lock state\n  only updates on re-fetch.\n\n| Cancel\n| `cancel-supplier/`; `CancelSupplierService` (+ mock)\n| See below.\n\n| Win back\n| `winback-confirmation-modal/`; `WinbackService`\n| Re-activates a cancelled supplier. Reasons from `winback/reasons`.\n\n| Write off\n| `add-writeoff/`; `writeoff` on the details model\n| Requires `MANAGE_BAD_DEBT`.\n\n| Request collection\n| `request-collection/`; `RequestCollectionService`\n| Full CRUD on `suppliers/:key/collections` plus\n  `suppliers/:key/collections_reasons`.\n\n| Delete account\n| `delete-supplier-account/`; `DeleteSupplierAccountService`;\n  flag `DELETE_SUPPLIER`\n| See below.\n\n| Change password / reset\n| `supplier-change-password-dialog/`;\n  `shared/reset-password/` at `/reset-password`\n| Operator-initiated credential reset.\n|===\n\n=== Cancellation\n\n`CancelSupplierService` on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET active_marketplaces/:supplierKey`\n| Which marketplaces are still active — a cancellation precondition check.\n\n| `GET suppliers/:supplierKey?taxId=1`\n| Supplier detail including tax id, for the confirmation screen.\n\n| `GET cancellation_reasons`\n| The reason list.\n\n| `PUT suppliers/:supplierKey/acc_cancellation`\n| Perform the cancellation.\n\n| `GET suppliers/:supplierKey/get_acc_cancellation_reason`\n| Read the recorded reason.\n\n| `POST suppliers/:supplierKey/change_acc_cancellation_reason`\n| Amend the reason after the fact.\n|===\n\n`cancelationStatus` on the details model tracks `none` / `pending` / `canceled` — so\ncancellation is a two-stage process, not immediate.\n\n`CancelEmitService` (its own `CancelEmitModule.forRoot()`, imported in `AppModule`) is\na small `Subject` bus that notifies the workspace when cancellation or win-back state\nchanges, so panels re-render without a store round trip:\n\n[source,typescript]\n----\nonCancellationChange:     Subject<boolean>\nonWinbackChange:          Subject<void>\neditOnCancellationChange: Subject<string>\n----\n\n[NOTE]\n====\n`setCancellation()` *toggles* an internal boolean and emits it. It does not take the\nnew state as an argument, so two callers can desynchronise it from reality. Treat it\nas a change notification, not a source of truth.\n====\n\n=== Account deletion\n\n`DeleteSupplierAccountService`, behind the `DELETE_SUPPLIER` feature flag:\n\n[source]\n----\nGET  supplier/:supplierKey/remove_supplier_account   → { can_be_removed, … }\nPOST supplier/:supplierKey/remove_supplier_account   → perform deletion\n----\n\n`canBeRemoved()` maps `can_be_removed` and **catches errors to `false`** — a failed\nprecondition check reads as \"cannot be removed\", which is the safe default.\n\nNote the path segment is singular `supplier/`, unlike almost every other endpoint\n(`suppliers/`). Easy to mistype.\n\n== The Actions menu\n\nThe sidebar *Actions* group is visible only when the layout slice says\n`actionsVisible` (true on supplier pages, not on the list) and the user is not\n`REST_UW_ACCESS` / `ADVANCE_AUDITORS`. Items:\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Menu item | Folder | What it does\n\n| Import ▸ Load Earnings\n| `load-earnings/`\n| Starts a marketplace earnings load. Long-running — progress arrives over the\n  B2B task-manager SSE stream. Can also be triggered by EVA via\n  `ONBOARDING__INIT_LOAD_EARNINGS_PROCESS`.\n\n| Import ▸ Load Remittance\n| `load-remittance/`\n| Loads the marketplace remittance. Conditionally shown\n  (`shouldShowRemitance` in the sidebar component).\n\n| Add chargeback\n| `add-chargeback/`\n| Charges a reversal back to the supplier's balance.\n\n| Add Bad Debt Recovery\n| `add-recovery/`\n| Records a recovery against a written-off balance\n  (`MANAGE_BAD_DEBT`).\n\n| Add receivable\n| `add-receivable/` with `service/receivable.service.ts` (provided in `AppModule`)\n| Adds a receivable to fund against.\n\n| Add writeoff\n| `add-writeoff/`\n| Recognises an unrecoverable balance.\n\n| Add New Default Contract\n| `add-contract/`\n| Only shown when `isSupplierActive`. See\n  xref:fees-and-reserves.adoc#contracts[contracts].\n\n| Edit MP Statement\n| `edit-marketplace-statement/`\n| Corrects a marketplace statement\n  (`MP_STATEMENTS_MODIFY` / `MP_STATEMENTS_DELETE`).\n\n| Add MP Payment\n| `add-marketplace-payment/`\n| Records a marketplace payment (`INT_MP_PMTS`).\n\n| Add Zero MP Payment\n| `add-zero-marketplace-payment/`\n| Records a zero-value marketplace payment — used to close a period with no payout.\n\n| Adjust Payability Fee\n| `adjust-payability-fee/`\n| One-off fee adjustment (`INT_FEE_UPD`), distinct from fee *schedules* — see\n  xref:fees-and-reserves.adoc[fees and reserves].\n\n| Add Fund Transfer Request\n| `create-transfer-request/` with\n  `suppliers/model/transfer-payment-details.model.ts`\n| Requests a fund transfer, which becomes part of a batch — see\n  xref:processes.adoc[processes].\n|===\n\nAlso present but reached from elsewhere:\n`add-marketplace-statement/`, `marketplace-access-details/`,\n`actions-import-menu/`, `transaction-modal/`, `kyc-documentation/`,\n`supplier-statements/`.\n\n`shared/actions-menu/` and `shared/actions-add-fabs/` provide the menu and\nfloating-action-button shells; `crm-tools/actions-add-fabs/` is the CRM equivalent.\n\n== Related supplier operations\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Operation | Implementation\n\n| Log in as supplier\n| `suppliers/services/login-as-supplier/login-as-supplier.service.ts` using\n  `env.supplierUrl`, with `SupplierAuthTokenService` (`authToolsApiUrl`) issuing the\n  token and `SupplierService.getToken()` (`suppliers/:key/get_token`). Requires the\n  companion browser extension — hence\n  `messageCodes.installExtension` and `cantReceiveToken` in `MessageService`. In test\n  environments only `supplier` and `supplier4` sites are permitted\n  (`inTestEnvironmentsCanOnlyTo`). The EVA message\n  `MAIN_HEADER__LOGIN_AS_SUPPLIER` triggers the same flow from the embedded header.\n\n| Log in to Amazon\n| `PybExtensionService` plus the `MAIN_HEADER__LOGIN_TO_AMAZON` message.\n\n| Referral link\n| `suppliers/services/supplier-referral.service.ts` using\n  `env.supplierReferralUrl`.\n\n| Email preferences\n| `suppliers/services/supplier-email-preferences.service.ts` and\n  `supplier-email-preferences/` — see xref:emails.adoc[emails].\n\n| Deal link\n| `extDealId` on the supplier plus `MAIN_HEADER__NAVIGATE_BY_DEAL_LINK` — jumps to\n  the external sales CRM.\n|===\n\n== Permissions relevant to lifecycle actions\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Permission | Gates\n\n| `INT_XACT_CNCL`\n| Transaction cancellation.\n\n| `INT_FEE_UPD`\n| Fee adjustments.\n\n| `INT_MP_PMTS`\n| Marketplace payments.\n\n| `INT_RCV`\n| Receivables.\n\n| `MANAGE_PMT`\n| Payment management.\n\n| `MANAGE_BAD_DEBT`\n| Write-offs and bad-debt recovery.\n\n| `MP_STATEMENTS_MODIFY`, `MP_STATEMENTS_DELETE`\n| Marketplace statement edits.\n\n| `MP_LOANS_MODIFY`, `MP_LOANS_DELETE`\n| Amazon-lending loans (enforced by `SubPermissionsGuard` on the route).\n\n| `INT_SUP_CRED`\n| Supplier credentials.\n\n| `INT_UPDATE`, `INT_SUPER_USER`\n| General update and super-user.\n|===\n\nThe client hides UI for these; the backend enforces them. See\nxref:platform:authorization.adoc[authorisation] and\nxref:reference:permission-catalog.adoc[the permission catalogue].\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Optimistic hide, pessimistic suspend\n| The hide toggle flips local state on success; suspend and lock do not. Three\n  toggles, three different refresh behaviours.\n\n| `TOGGLE_LOCK_SUPPLIER` has no reducer case\n| The action round-trips but the store is not updated. Re-fetch to see the result.\n\n| Singular `supplier/` in delete endpoints\n| Unlike every neighbouring endpoint.\n\n| `CancelEmitService.setCancellation()` toggles blindly\n| It does not accept the intended state.\n\n| Status badges key on display strings\n| Backend wording changes degrade badges silently.\n\n| Zero MP payment is a real operation\n| It is not a no-op guard against mistakes; it records a zero-value payment\n  deliberately.\n\n| Load Earnings/Remittance are asynchronous\n| They return before the work completes. Progress comes from the SSE stream — see\n  xref:platform:realtime.adoc[real-time streams].\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:marketplaces.adoc","title":"Marketplaces and gateways","description":"The marketplace catalogue, payment gateways, the supplier-to-marketplace link, and marketplace statements.","url":"https://docx.payability.com/internal-admin/domains/marketplaces.html","navOrder":25,"source":"= Marketplaces and gateways\n:description: The marketplace catalogue, payment gateways, the supplier-to-marketplace link, and marketplace statements.\n\n== What it is\n\nA *marketplace* is a sales channel Payability funds against — Amazon Seller Central,\nAmazon Vendor Central/Express, Walmart, Target, eBay, Shopify, Newegg, Volusion,\nMoreCommerce, Tophatter, PingPong — plus payment processors treated the same way\n(PayPal, Stripe, Payoneer, Authorize.net).\n\nTwo distinct things are easy to conflate:\n\n*The marketplace* (`marketplaceKey`)::\nA catalogue entry, global across all suppliers. Maintained under `/marketplaces`.\n\n*The marketplace-supplier link* (`mpSupKey` / `mpSupplierKey`)::\nOne supplier's account on one marketplace. Funding settings, opt-in periods,\nminimum-fee overrides, statements and gateway connections all attach *here*, not to\nthe supplier.\n\nAlmost every funding-related bug traced to \"the wrong marketplace\" is actually a\nconfusion between these two keys.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Catalogue routes\n| `/marketplaces/{list,add,:marketplaceKey/edit}` →\n  `marketplaces/marketplace-list/`, `marketplaces/marketplace/`\n\n| Catalogue services\n| `marketplaces/service/marketplace-repository.ts` (implements\n  `MarketplacesAbstractService`, with `marketplace-repository-mock.ts`),\n  `marketplaces/service/gateway.service.ts`\n\n| Catalogue models\n| `marketplaces/model/{marketplace-model,marketplace-list-model,marketplace-status.model,gateway.model}.ts`\n\n| Catalogue store\n| `marketplaces/store/` — the `marketplace` slice\n\n| Supplier link routes\n| `/suppliers/:supplierKey/marketplaces/{list,edit/:mpSupplierKey}` →\n  `suppliers/supplier/supplier-marketplaces/`\n\n| Supplier link services\n| `suppliers/services/supplier-marketplaces.service.ts` plus the three pricing\n  services (funding, min-fee overrides, opt-in periods) — see\n  xref:fees-and-reserves.adoc[fees and reserves]\n\n| Statements\n| `/suppliers/:supplierKey/statements` →\n  `suppliers/supplier/supplier-marketplace-statements/`;\n  `shared/consts/available-statements.const.ts`\n|===\n\n== The catalogue\n\n`MarketplaceRepository` on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET marketplaces`\n| List all marketplaces.\n\n| `GET marketplaces/:uuid`\n| One marketplace.\n\n| `POST marketplaces`\n| Create.\n\n| `PUT marketplaces/:uuid`\n| Update.\n\n| `GET marketplace_period_groups`\n| Funding period groups — the settlement cadences a marketplace can use.\n\n| `GET free_trials`\n| Free-trial configurations.\n|===\n\nThere is no delete. A marketplace is retired by status, not removal.\n\n`MARKETPLACE` (`shared/enums/marketplace.enum.ts`) enumerates only four names and\ncarries a `// TODO: should be extended` comment:\n\n[source,typescript]\n----\nexport enum MARKETPLACE {\n  AMAZON_SELLER_CENTRAL  = 'Amazon Seller Central',\n  AMAZON_VENDOR_CENTRAL  = 'Amazon Vendor Central',\n  AMAZON_VENDOR_EXPRESS  = 'Amazon Vendor Express',\n  PAYABILITY             = 'Payability',\n}\n----\n\n[CAUTION]\n====\nThis enum is *not* the marketplace catalogue — it is a partial list of names used for\nAmazon-specific branching. The authoritative identifiers are the UUID constants in\n`suppliers/supplier/supplier-onboarding/supplier-onboarding.service.ts`\n(`AMAZON_SELLER_CENTRAL_MARKETPLACE_KEY`, `TARGET_MARKETPLACE_KEY`,\n`WALLMART_MARKETPLACE_KEY` and eleven more). Matching by name is fragile; matching by\nkey is correct. `SupplierDetailsModel.isAmazonVendor` / `isAmazonSeller` unfortunately\nmatch by name.\n====\n\n== Payment gateways\n\nSome marketplaces settle through a payment gateway rather than paying Payability\ndirectly. `GatewayService` (which reads `environment` directly rather than through\n`AppConfigService` — legacy):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET payment_gateways`\n| The available gateways.\n\n| `POST supplier/:marketplaceSupplierKey/create_and_connect/:gatewayKey`\n| Connect a gateway to a marketplace link, with\n  `{ bankAccountNumber }`.\n\n| `POST supplier/:marketplaceSupplierKey/disconnect/:gatewayKey`\n| Disconnect.\n\n| `POST supplier/:mpKey/update_payment_gateway_bank_account_number`\n| Update the bank account on an existing connection\n  (`SupplierMarketplacesService`).\n|===\n\nNote the singular `supplier/` prefix and that the path parameter is the\n*marketplace-supplier* key even though the segment reads `supplier`.\n\nThe `marketplace` store slice exists solely to hold the gateway list:\n\n[source,typescript]\n----\nexport interface IMarketplacesState { possiblePaymentGateways: IPaymentGateway[]; }\n// single action: SET_POSSIBLE_GATEWAYS\n----\n\nIt is a map-of-handlers reducer with no effects. Gateway connection changes on the\nsupplier side dispatch `ADD_PAYMENT_GATEWAY` in the *supplier* slice — which, as\nnoted in xref:suppliers.adoc[suppliers], resets that slice to `initialState`.\n\nLinking gateways is gated by the `MANAGE_LINKING_PAYMENT_GATEWAYS` permission.\n\n== The supplier-marketplace link\n\n`SupplierMarketplacesService`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Base URL | Purpose\n\n| `GET suppliers/:supplierKey/mp/:mpSupKey`\n| `internalApiUrl`\n| The link's details (`SupplierMarketplaceDetailsModel`).\n\n| `PUT suppliers/:supplierKey/mp/:mpSupKey`\n| `internalApiUrl`\n| Update the link.\n\n| `PUT suppliers/:supplierKey/marketplace_suspend_reason`\n| `internalApiUrl`\n| Record why a marketplace was suspended for this supplier.\n\n| `POST supplier/:mpKey/update_payment_gateway_bank_account_number`\n| `internalApiUrl`\n| Gateway bank account.\n\n| `POST /platforms/:marketplaceKey/update_sales_data`\n| `marketplaceApiUrl`\n| Refresh sales data — the *only* call in the app to the marketplace service.\n|===\n\n[NOTE]\n====\nThis one service spans two backends. `update_sales_data` goes to\n`marketplaceApiUrl`, everything else to `internalApiUrl`. The URL is built as\n`$\\{marketplaceApiUrl}/platforms/…` — with a leading slash after a base that already\nends in one, producing a double slash. It works, but do not copy the pattern.\n====\n\n=== Models\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Model | Contents\n\n| `SupplierMarketplaceListModel`\n| The summary shown on the supplier record: `mpSupKey`, `marketplaceKey`,\n  `marketplaceCode`, `marketplaceName`, `marketplaceType`, `externalSupplierId`,\n  `estimatedMonthlyRevenue`, `fundingParticulars`, `draft`, `marketplaceStatus`,\n  `paymentGateways`, `fundingPeriodGroupCode`, `mpPeriodGroupCode`.\n\n| `SupplierMarketplaceDetailsModel`\n| The full link record (uses `@inheritSerialization`).\n\n| `SupplierMarketplaceModel`\n| An *aggregate* — details + funding + min-fee overrides + opt-in periods. Store-only,\n  no serialisation decorators.\n\n| `SupplierMarketplaceGatewayModel`\n| A gateway connection on the link.\n|===\n\n`draft` distinguishes a link that has been started but not completed — relevant during\nonboarding.\n\nAmazon Seller Central links also show a *Scraper* card with one switch, *Passkey Enroll*\n(`passkeyEnroll`, GAR-2454). When it is on, the earnings scraper registers Payability's\npasskey on the seller account at its next password sign-in. The card saves through the same\n`PUT suppliers/:supplierKey/mp/:mpSupKey` as the other cards; the backend writes the flag\nonly when its value changed, so saving another card leaves it alone.\n\n=== Store integration\n\nFour actions in the `supplier` slice load link data:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | Behaviour\n\n| `FETCH_SUPPLIER_MARKETPLACES` / `_ERROR`\n| Loads all links via\n  `SupplierMarketplacesService.prepareMarketplaceListObservable`. If the supplier has\n  no marketplaces on the details model, it short-circuits to an empty array without\n  calling the API.\n\n| `FETCH_SUPPLIER_MARKETPLACE` / `_SUCCESS` / `_ERROR`\n| Loads one link. The request-phase reducer case does\n  `delete state.supplierModel.marketplaces` — a direct mutation. `_SUCCESS` then\n  either replaces the matching entry (found by `mpSupKey`) or sets the array to a\n  single-element list.\n\n| `FETCH_SUPPLIER_MARKETPLACE_DETAILS_PART` / `_ERROR`\n| Loads a part of the link's details for partial editing.\n|===\n\n[WARNING]\n====\n`FETCH_SUPPLIER_MARKETPLACE_SUCCESS` sets `supplierModel.marketplaces = [model]` when\nthe key is not already present, *replacing* the whole array rather than appending.\nLoading a single link after loading the list therefore discards the list. Load order\nmatters.\n====\n\n== Marketplace statements\n\nA *statement* is the marketplace's periodic settlement report. Screens:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Surface | Detail\n\n| `/suppliers/:key/statements`\n| `SupplierMarketplaceStatementsComponent`. Routed with\n  `RESTRICTED_UW_ACCESS_CONFIG_MARKETPLACE_STATEMENTS`, which adds report metadata to\n  `data`: `reportName: 'mp_statements'`,\n  `reportConfigName: 'v_mp_statements'`,\n  `supplierKeyInQueryParamsNotNeeded: true`. The tab is therefore rendered by the\n  *report* machinery over a database view — see xref:reports.adoc[reports].\n\n| Actions ▸ Edit MP Statement\n| `suppliers/supplier/edit-marketplace-statement/`\n  (`MP_STATEMENTS_MODIFY` / `MP_STATEMENTS_DELETE`).\n\n| Actions ▸ Add MP Statement\n| `suppliers/supplier/add-marketplace-statement/`.\n\n| Actions ▸ Add / Add Zero MP Payment\n| `add-marketplace-payment/`, `add-zero-marketplace-payment/`\n  (`INT_MP_PMTS`).\n\n| Statement catalogue\n| `shared/consts/available-statements.const.ts`.\n\n| Supplier statements folder\n| `suppliers/supplier/supplier-statements/`.\n|===\n\n== Amazon lending\n\nAmazon offers its own loans, which compete with a Payability advance. The app records\nthem per supplier:\n\n* Routes `/suppliers/:key/amazon-lending/{list,add-loan,loan/:loanKey}`.\n* `suppliers/supplier/supplier-amazon-lending/`.\n* `suppliers/services/marketplace-loans.service.ts` (+ mock),\n  `suppliers/model/marketplace-loan.model.ts`.\n* Add and edit are gated by `SubPermissionsGuard` on `MP_LOANS_MODIFY` /\n  `MP_LOANS_DELETE` — the only routes in the app using that guard.\n\nExisting Amazon loans also appear as a card in initial underwriting — see\nxref:supplier-underwriting.adoc[underwriting].\n\n== Marketplace access and permissions\n\nObtaining and maintaining access to a supplier's marketplace account is an onboarding\nconcern, implemented in the five `marketplace-auth-*` folders and the two\n`supplier-onboarding-marketplace-*` folders. See\nxref:supplier-onboarding.adoc[onboarding and KYC].\n`suppliers/supplier/marketplace-access-details/` shows the current access state\noutside onboarding.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Two keys, similar names\n| `marketplaceKey` (catalogue) vs `mpSupKey` / `mpSupplierKey` (link). The\n  `fees/reserve/edit/:marketplaceKey` route takes the *catalogue* key while\n  `marketplaces/edit/:mpSupplierKey` takes the *link* key.\n\n| Endpoint prefix `supplier/` takes a link key\n| `supplier/:marketplaceSupplierKey/create_and_connect/:gatewayKey`.\n\n| `MARKETPLACE` enum is incomplete by design\n| Four entries with a TODO. Not a catalogue.\n\n| Name-based Amazon detection\n| `isAmazonVendor` / `isAmazonSeller` match display names.\n\n| Single-link load clobbers the list\n| See the warning above.\n\n| `GatewayService` bypasses `AppConfigService`\n| It imports `environment` directly. New services should not.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:processes.adoc","title":"Payment processes and batches","description":"How money actually leaves Payability — fund-transfer batches, their lifecycle, per-payment review and the payment file.","url":"https://docx.payability.com/internal-admin/domains/processes.html","navOrder":26,"source":"= Payment processes and batches\n:description: How money actually leaves Payability — fund-transfer batches, their lifecycle, per-payment review and the payment file.\n\n== What it is\n\nPayments to suppliers are released in *batches* (called \"processes\" in the routing and\n\"fund transfer batches\" in the UI). A batch is created with selection criteria, filled\nwith the matching payments, reviewed and approved payment by payment, locked,\nsubmitted, and finally produces a *payment file* sent to the bank.\n\nThis is the highest-consequence area of the application: a mistake here moves real\nmoney.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `processes/processes-routing.routes.ts` — two route tables, `/processes` and the\n  misspelled `/proceses`\n\n| Components\n| `processes/process-list/`, `process-add/`, `process-edit/`,\n  `process-batches/`, `supplier-payments-details/`, `pending-edit/`\n\n| Services\n| `processes/service/payment-batch-repository-service.ts` (implements\n  `PaymentBatchesAbstractService`),\n  `payment-batch-downloader.service.ts` (+ abstract + mock),\n  `processes/service/pending-review.service.ts`\n\n| Models\n| `processes/model/supplier-payment-batch-model.ts`,\n  `supplier-payment-batch-list-model.ts`,\n  `supplier-payment-model.ts`\n\n| Balance approval\n| `processes/balance-approval/` — see xref:balance-approval.adoc[balance approval]\n|===\n\n== Routes\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Component\n\n| `/processes/list`\n| `ProcessListComponent` — all batches.\n\n| `/processes/:processKey/batches`\n| `ProcessBatchesComponent`.\n\n| `/processes/:processKey/new`\n| `ProcessAddComponent`.\n\n| `/processes/:processKey/edit`\n| `ProcessEditComponent`.\n\n| `/processes/:processKey/:paymentKey`\n| `SupplierPaymentsDetailsComponent` — one payment inside a batch.\n\n| `/processes/:supKey`\n| `PendingEditComponent`.\n\n| `/processes/approval`\n| `BalanceApprovalComponent`.\n\n| `/proceses/approval`\n| `BalanceApprovalComponent` again, in a second route table.\n|===\n\n[WARNING]\n====\nRoute ordering hazard. In the `/processes` table, `:processKey/:paymentKey` and\n`:supKey` are both declared *before* `approval`, so `/processes/approval` matches\n`:supKey` and renders `PendingEditComponent`, not balance approval. That is why the\nsidebar links to `/proceses/approval` — the second, misspelled table exists to work\naround it.\n\nDo not \"tidy\" either table without checking bookmarks, the sidebar, and any deep links\nin emails or runbooks.\n====\n\nThe sidebar's *Payments* group links to `/processes/supplier-payments/batches` and\n`/proceses/approval`. The first does not match any declared route path literally —\nit resolves through `:processKey/batches` with `processKey = 'supplier-payments'`, i.e.\na *literal string used as a key*. Worth knowing before changing the batches route.\n\n== Batch creation\n\n`SupplierPaymentNewBatchModel` is the creation payload, and its fields *are* the\nselection criteria:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `name`\n| Batch label.\n\n| `plannedPaymentTime`\n| When the batch is intended to pay.\n\n| `reviewers`\n| Who must review it.\n\n| `locked`\n| Whether it starts locked.\n\n| `scheduledPayments`\n| Include scheduled payments.\n\n| `instantAccess`\n| Include Instant Access payments.\n\n| `proposedAdvanceLine`\n| Include proposed advance-line disbursements.\n\n| `debitPayments`\n| This is a debit batch (money in) rather than credit (money out).\n\n| `addAchPmts`, `addEfsPmts`, `addPpmPmts`, `addWirPmts`, `addLlpPmts`\n| Which payment *types* to include: ACH, EFS card, PPM, wire, LLP.\n\n| `paymentBatchFeeCode`\n| Optional fee code applied to the batch.\n|===\n\n`SupplierPaymentBatchModel` adds the runtime view: `paymentBatchKey`,\n`paymentTypeCounts`, `paymentTotal`, `numPayments`, `numApproved`, `numCancelled`,\n`numHold`, `numSentToBank`, `submitted`, `locked`, `internalTransfers`.\n\n`PaymentBatchRepositoryService.getItems` post-processes `paymentTypeCounts` into a\nhuman-readable `paymentTypes` string by concatenating the type names.\n\n== Batch endpoints\n\nAll on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET paymentBatches`\n| List (with `HttpParams` for paging/filtering). Returns\n  `{ results, total }`.\n\n| `GET paymentBatches/:batchId`\n| One batch.\n\n| `POST paymentBatches`\n| Create from `SupplierPaymentNewBatchModel`.\n\n| `PUT paymentBatches/:batchId`\n| Update.\n\n| `POST paymentBatches/:batchId/action`\n| Perform a workflow action (`SupplierPaymentBatchAction`) — approve, submit, cancel\n  and similar transitions.\n\n| `POST paymentBatches/:batchId/unlock`\n| Unlock a locked batch.\n\n| `GET paymentBatches/:batchId/status`\n| `SupplierPaymentWorkflowStatusModel` — where the batch is in its workflow.\n\n| `GET paymentBatches/:batchId/payments`\n| The credit payments in the batch.\n\n| `GET paymentBatches/:batchId/get_debit_payments`\n| The debit payments — a *different endpoint*, selected by an `isDebit` flag on\n  `getPayments`.\n\n| `PUT paymentBatches/:batchId/payments/:paymentId`\n| Update one payment (hold, approve, amend).\n\n| `DELETE paymentBatches/:batchId/payments/:paymentId`\n| Remove a payment from the batch.\n\n| `GET paymentBatches/:batchId/paymentFile?withAch=true`\n| The bank payment file (`PaymentBatchDownloaderService`).\n|===\n\n`saveItem` on the repository is a stub returning `null` with a `// [SM] TODO` — batch\nupdates go through `updateBatch`, not the generic abstract-service method.\n\n=== Serialisation quirk\n\nEvery write method calls `SerializeKeysTo(CamelCase)` before serialising — so batches\nare sent in `camelCase`, unlike most of the API which expects `snake_case`. Because\nthe casing setting is global in `cerialize`, this leaves `CamelCase` active for\nwhatever serialises next. See\nxref:architecture:data-modeling.adoc[data modelling].\n\n=== Payment normalisation\n\n`getPayments` fills in empty objects for missing `paymentOutcome` and `outcomeReason`\nso templates can bind without null checks:\n\n[source,typescript]\n----\nif (isNullOrUndefined(item.paymentOutcome)) { item.paymentOutcome = {}; }\nif (isNullOrUndefined(item.outcomeReason))  { item.outcomeReason  = {}; }\n----\n\n== Batch lifecycle\n\n[source]\n----\n  create                fill                  review\n┌──────────────┐   ┌───────────────┐   ┌────────────────────────┐\n│ POST         │──►│ backend selects│──►│ per-payment approve /  │\n│ paymentBatches│  │ matching pmts  │   │ hold / cancel / remove │\n│ (criteria)   │   │ by criteria    │   │ PUT|DELETE …/payments  │\n└──────────────┘   └───────────────┘   └───────────┬────────────┘\n                                                   │\n                    lock ◄─────── unlock ──────────┤\n                     │        POST …/unlock         │\n                     ▼                              ▼\n            ┌────────────────┐          ┌───────────────────────┐\n            │ POST …/action  │────────► │ submitted             │\n            │ (approve/submit)│         │ numSentToBank grows   │\n            └────────────────┘          └──────────┬────────────┘\n                                                   ▼\n                                        ┌───────────────────────┐\n                                        │ GET …/paymentFile     │\n                                        │ ?withAch=true         │\n                                        │ → file-saver download │\n                                        └───────────────────────┘\n----\n\nThe authoritative state is `GET …/status`; the counters on the batch model\n(`numApproved` / `numCancelled` / `numHold` / `numSentToBank`) are the summary view.\n\n== The payment file\n\n`PaymentBatchDownloaderService` builds\n`paymentBatches/:batchId/paymentFile?withAch=true` and appends a timestamp\n(`addTimestampToUrl`) to defeat caching, then hands the response to `file-saver`.\nThere is an abstract base (`PaymentBatchDownloaderAbstractService`) and a mock, so\nspecs can exercise the flow without downloading.\n\n== Pending review and pending edits\n\nTwo related-but-distinct things:\n\n`PendingReviewService`::\n`GET pending_review/` and `PUT /pending_review/:supplierKey` with\n`{ supplier_transfer_approval_ml_key, treasury_approved }`. This is the *balance\napproval* queue — see xref:balance-approval.adoc[balance approval].\n\n`PendingEditComponent`::\nRouted at `/processes/:supKey`, this is the pending-*edit* review — supplier changes\nflagged `pendApprovNeeded` that require a second approver. Note it is also what\n`/processes/approval` accidentally resolves to.\n\n[CAUTION]\n====\n`PendingReviewService.updatePendingReview` builds its URL as\n`${this.apiUrl}/pending_review/$\\{supplierKey}` while `apiUrl` already ends in a slash,\nproducing a double slash. `BalanceApprovalService` builds the same path correctly.\nTwo services, two spellings, same endpoint.\n====\n\n== Permissions\n\nThe `/processes` and `/proceses` tables use the standard deny clause\n(`except: [REST_UW_ACCESS, ADVANCE_AUDITORS]`) — the second table without\n`NgxPermissionsGuard` at all, only `RouteAccessResolver` and\n`FeatureFlagsGeneralGuard`.\n\nMeaningful control is by permission code enforced in components and the backend:\n`INT_PMT_ADMIN` (administer payments), `INT_PMT_REVIEW` (review payments),\n`MANAGE_PMT`, `FINANCE`. See\nxref:reference:permission-catalog.adoc[the permission catalogue].\n\n[NOTE]\n====\n`/proceses/approval` has no permission guard on the route. Anyone who passes\nauthentication and reaches that URL renders the balance-approval component; the data\ncalls behind it are what actually enforce access. Treat the route guard gap as a UX\ninconsistency, not an authorisation hole — but do not rely on the route for gating.\n====\n\n== Error handling\n\n`PaymentBatchRepositoryService` is unusual in *toasting from the service layer* — each\nmethod catches, toasts a specific message (\"Cannot fetch Payment Batches\", \"Cannot\nstore Payment Batch\", \"Cannot update Payment Batch\", \"Cannot make Payment Batch\nAction\", \"Cannot unlock Payment Batch\", \"Cannot update Payment\", \"Cannot delete\nPayment\", \"Cannot fetch Payment status\") and rethrows.\n\nCombined with `PendingInterceptorService`, which toasts any 5xx, a server error during\na batch operation produces two toasts. Expected, not a bug. See\nxref:platform:error-handling.adoc[error handling].\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| `/proceses` is intentional\n| The misspelling is load-bearing — it is how balance approval is reachable.\n\n| `supplier-payments` is a literal `processKey`\n| The sidebar's batches link relies on it.\n\n| Debit vs credit are different endpoints\n| `payments` vs `get_debit_payments`, selected by a boolean argument.\n\n| `camelCase` serialisation\n| Batch writes flip the global `cerialize` casing.\n\n| `saveItem` is a stub\n| Returns `null`.\n\n| Double slashes in URLs\n| `PendingReviewService.updatePendingReview`.\n\n| Fixture JSON in the module\n| `bbbb.json`, `payments.mock.json`, `processes-batches.mock.json` sit in\n  `processes/` and are test data, not runtime config.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:balance-approval.adoc","title":"Balance approval","description":"The treasury review gate before money moves — the ML recommendation, the risk signals shown, and how a decision is recorded.","url":"https://docx.payability.com/internal-admin/domains/balance-approval.html","navOrder":27,"source":"= Balance approval\n:description: The treasury review gate before money moves — the ML recommendation, the risk signals shown, and how a decision is recorded.\n\n== What it is\n\nBefore a supplier's balance is released, treasury reviews it. A machine-learning model\nproduces a recommendation and a set of risk signals; a human accepts, rejects or\noverrides it with a reason.\n\nThis is the last control before a payment enters a batch, so it is the screen with the\ndensest information in the application — around 70 fields per row.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/processes/approval` (shadowed — see below) and `/proceses/approval`\n\n| Component\n| `processes/balance-approval/balance-approval.component.*`\n\n| Contract\n| `processes/balance-approval/balance-approval.ts` — the `IBalanceApproval` interface\n\n| Service\n| `processes/balance-approval/balance-approval.service.ts`, plus\n  `balance-approval-fake-service.ts` and `balance-approval-mock-data.ts`\n\n| Types\n| `processes/balance-approval/balance-approval-interfaces.ts`\n\n| Dialogs\n| `approval-notes-modal/`, `description-modal/`\n\n| Styling\n| `models/balance-headers-classes.enum.ts`\n|===\n\n[IMPORTANT]\n====\nReach this screen via `/proceses/approval` (one `s`). In the `/processes` table the\n`:supKey` wildcard is declared before `approval`, so `/processes/approval` renders\n`PendingEditComponent` instead. See xref:processes.adoc[processes] for why.\n====\n\n== Endpoints\n\n`BalanceApprovalService` on `internalApiUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET pending_review/`\n| The review queue. Takes `limit`, `offset` and arbitrary filter keys copied from a\n  `DataTableQueryParams` object (`limit`/`offset` are excluded from the copy so they\n  are not duplicated).\n\n| `GET pending_review/statistic`\n| Queue-level counters (`IReviewStatistics`).\n\n| `PUT pending_review/:supplierKey`\n| Record a decision.\n\n| `GET lookup/codes/payment.approval.outcomeReason`\n| The outcome-reason dictionary (`IOutcomeReason[]`: `codeValue`, `displayName`,\n  `description`).\n|===\n\n`PendingReviewService` (in `processes/service/`) hits the same `pending_review`\nendpoints with a narrower signature — two services, one backend resource. See the\ndouble-slash note in xref:processes.adoc[processes].\n\n== The `IResultField` wrapper\n\nEvery field in a queue row is wrapped, not bare:\n\n[source,typescript]\n----\nexport interface IResultField {\n  Value?: string | boolean | number;\n  Highlight: string;\n}\n----\n\n`Highlight` is a *server-supplied* styling hint — the backend decides which cells to\ndraw attention to. So visual emphasis in this grid is data, not front-end logic. Note\nthe capitalised property names; they do not follow the app's usual camelCase and are\nnot remapped.\n\n`BalanceHeaderClasses.Db = 'db'` is the one front-end styling constant, marking\ndatabase-sourced header groups.\n\n== What a row shows\n\nRoughly seventy fields per supplier, grouped by what they tell the reviewer:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Group | Fields\n\n| Identity\n| `supplier_key`, `supplier_name`,\n  `supplier_transfer_approval_ml_key`, `last_txid`, `create_ts`, `update_ts`,\n  `last_login_id`, `superceded` (sic).\n\n| Balances\n| `avail_balance`, `delta_avail`, `delta_check`, `last_end_balance`,\n  `transfer_balance`, `current_transfer_balance`, `liability`.\n\n| Marketplace exposure\n| `pyb_due_from_mp_balance`, `mp_due_from_marketplace_balance`,\n  `mp_due_from_mp_balance`, `due_from_mp_delta`, `mp_payments_7days`,\n  `mp_pmt_excess_24`.\n\n| Receivables\n| `rcv_purchased_24`, `rcv_released_24`, `dilution`.\n\n| Arrears\n| `past_due_days`, `past_due_amount`, `past_due_check`.\n\n| Marketplace performance\n| `cancellation_rate`, `order_defect_rate`, `order_defect_value_short_term`,\n  `late_responses`, `late_shipment_rate`, `late_shipment_value_30_days`,\n  `ml_complaints`.\n\n| Trend\n| `short_to_long_mov_avg_ratio` and its two variants\n  `…_rcv` (receivables) and `…_cbk` (chargebacks).\n\n| Context\n| `holidays`, `check_payee_diverted`, `payability_status`,\n  `cancelation_status` (sic).\n\n| ML output\n| `ml_approved`, `ml_probability`, `ml_balance_approval`,\n  `ml_balance_approval_details`, `ml_financial`, `ml_financial_reason`,\n  `ml_order_defect`, `ml_cancellation`, `ml_late_shipment`, `ml_past_due`,\n  `ml_ok_days`, `ml_ba_payments_decision`, plus the previous-model comparison\n  `ml_balance_approval_old` and `ml_probability_old`.\n\n| Decision\n| `treasury_approved`, `treasury_approved_ts`, `treasury_approved_by`,\n  `rejected`, `reasons`, `notes`, `exceptions`, `existing_exceptions`.\n|===\n\n[NOTE]\n====\n`ml_*_old` alongside `ml_*` means the screen shows *two model generations side by\nside* — reviewers can see whether a new model would have decided differently. Do not\nremove the `_old` fields assuming they are dead.\n====\n\n=== The ratio substitution rule\n\n`getData` contains the one piece of business logic in the service:\n\n[source,typescript]\n----\nif (item.hasOwnProperty('short_to_long_mov_avg_ratio_rcv') &&\n    item['short_to_long_mov_avg_ratio_rcv'].Value !== 0) {\n  mappedData.results[i]['short_to_long_mov_avg_ratio'] =\n    item['short_to_long_mov_avg_ratio_rcv'];\n}\n----\n\nWhen a non-zero receivables-based ratio is present it *overwrites* the general\nshort-to-long moving-average ratio. The reviewer sees the receivables variant in the\ngeneral column, with no indication that a substitution happened.\n\n[CAUTION]\n====\nThis substitution is silent and only guards against `0`, not `null`/`undefined`\n`Value`s. If you are reconciling a displayed ratio against the raw API response, this\nis why they differ.\n====\n\nThe mapping only runs when the response has both `total` and `results`; otherwise the\nraw response is passed through unchanged.\n\n== Recording a decision\n\n[source,typescript]\n----\nsetDecision(key, value, suppKey, mlKey, reason?) {\n  const data = { [key]: value, supplier_transfer_approval_ml_key: mlKey };\n  if (reason) { data['reasons'] = reason; }\n  return this.httpClient.put(`${apiUrl}pending_review/${suppKey}`, data);\n}\n----\n\nThe *field being set is a parameter*, so one method covers every decision column\n(`treasury_approved`, `rejected`, `notes`, `exceptions`, …). `mlKey` scopes the\ndecision to a specific model evaluation, which is what prevents a decision being\napplied to a superseded row.\n\nReasons come from the `payment.approval.outcomeReason` lookup. `approval-notes-modal/`\ncaptures free-text notes and `description-modal/` shows a field's explanation.\n\n== Queue statistics\n\n`IReviewStatistics` drives the header counters:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Counter | Meaning\n\n| `treasuryApprovedTrue` / `…False` / `…Reject` / `…None`\n| Decision distribution — note that `False` and `Reject` are *distinct*: an explicit\n  \"not approved\" versus a rejection.\n\n| `amazonHealthPass` / `amazonHealthFail`\n| Amazon account-health gate results.\n\n| `financialMlPass` / `financialMLFail`\n| Financial-model gate results. Note the inconsistent casing (`Ml` vs `ML`) between\n  the two members — match the interface exactly.\n|===\n\n== Testing seam\n\nUnusually, this feature has three data sources:\n\n* `BalanceApprovalService` — the real one.\n* `balance-approval-fake-service.ts` — a fake implementing `IBalanceApproval`.\n* `balance-approval-mock-data.ts` — static rows.\n\nThe `IBalanceApproval` interface is what makes the substitution clean. It is a good\npattern to copy for other data-dense screens.\n\n== Related gates\n\nBalance approval is one of several checks before money moves:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Gate | Where\n\n| Pending edit approval\n| Supplier changes flagged `pendApprovNeeded`, reviewed in\n  `processes/pending-edit/`.\n\n| Amazon health rules\n| xref:supplier-underwriting.adoc#health[underwriting].\n\n| Batch review\n| Per-payment approve/hold/cancel inside a batch —\n  xref:processes.adoc[processes].\n\n| Underwriting status\n| `UwStatus` on the advance — xref:advances.adoc[advances].\n|===\n\n== Gotchas\n\n* Use `/proceses/approval`, not `/processes/approval`.\n* `superceded` and `cancelation_status` are misspelled in the API contract; the\n  interface matches the API, so keep the misspellings.\n* `Value` and `Highlight` are capitalised.\n* The receivables-ratio substitution is silent.\n* The route at `/proceses` has no `NgxPermissionsGuard`; access control is enforced by\n  the endpoints, not the route.\n"},{"component":"internal-admin","module":"domains","path":"domains:crm.adoc","title":"CRM tools and tasks","description":"The activity timeline attached to any entity, the task workflow that drives staff work queues, and how the normalised CRM store works.","url":"https://docx.payability.com/internal-admin/domains/crm.html","navOrder":28,"source":"= CRM tools and tasks\n:description: The activity timeline attached to any entity, the task workflow that drives staff work queues, and how the normalised CRM store works.\n\n== What it is\n\nTwo things sharing one subsystem:\n\n*The activity timeline* — notes, tasks, files, emails, batch emails and system events\nattached to a *target*. For supplier pages the target is the supplier key, so the CRM\npanel beside the supplier workspace is the supplier's history.\n\n*The task system* — the staff work queue. Tasks have an assignee (user or group),\npriority, status and due date, and the sidebar shows live counters for \"your tasks\" and\n\"group tasks\".\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `/tasks/list/:filterId/filter` (`crm-tools/tasks-routing.routes.ts`);\n  `/emails/*` reuses `crm-tools/emails/` components — see xref:emails.adoc[emails]\n\n| Components\n| `crm-tools/` — `crm-sidebar/`, `actions-list/`, `actions-list-pinned/`,\n  `actions-add/`, `actions-add-fabs/`, `notes/`, `tasks/`, `files/`, `emails/`,\n  `comments/`, `events/`\n\n| Store\n| `crm-tools/store/` — the `crm` slice\n\n| Services\n| `crm-tools/services/` — `crm.service.ts`, `tasks.service.ts`,\n  `tasks-filters.service.ts`, `comments.service.ts`, `emails.service.ts`,\n  `emails-status.service.ts`, `file-types.service.ts`,\n  `action-tab-calculator.service.ts` (each with a `-mock` sibling)\n\n| Models\n| `crm-tools/models/` — `base-crm.model.ts`, `note.model.ts`, `task.model.ts`,\n  `file.model.ts`, `email.model.ts`, `event.model.ts`, `batch.model.ts`,\n  `comment.model.ts`, `document.model.ts`, `crm-user.model.ts`, `meta.model.ts`,\n  `crm-item.type.ts`, `task-filter.model.ts`, and the `*-data.model.ts` write shapes\n\n| Counters\n| `shared/task-count-badge/`\n|===\n\n== Backends\n\nThe CRM spans three hosts, which is why `CrmService` is the most header-heavy service\nin the app:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Base URL | Used for\n\n| `actionsToolsUrl`\n| `activity/`, `notes/`, `files/`, `tasks/`, `tasks/filters/counts/` — the durable\n  CRM store.\n\n| `eventLoggerUrl`\n| `es-changelog` — the system event stream, read from Elasticsearch.\n\n| `messagingUrl`\n| Outbound email.\n|===\n\n=== Headers\n\n`CrmService` maintains its headers by *subscribing to the store in its constructor*:\n\n[source,typescript]\n----\nthis.headers['X-InstanceId']              = '' + appConfig.env.instanceId;   // 8001\nthis.headers['X-EventLogger-InstanceId']  = '' + appConfig.env.instanceId;\n\nthis.store.select(selectAuthToken).subscribe((jwt) => this.headers['Authorization'] = jwt);\nthis.store.select('crm').subscribe((data) => {\n  if (data.targetUuid) { this.headers['X-Actions-TargetUUID'] = data.targetUuid; }\n});\n----\n\nTwo consequences:\n\n* The *current* target UUID is implicit in every request. A call issued while the\n  target is changing can be scoped to the wrong entity.\n* For the event-logger call, `listActionsEvents` copies the header across:\n  `X-EventLogger-TargetUUID = X-Actions-TargetUUID`, then deletes the original.\n\n[NOTE]\n====\n`Authorization` here is the raw JWT with no scheme — in addition to the\n`X-Session-Token` header that `AuthInterceptor` adds. Actions Tools accepts both.\n====\n\n== The normalised store\n\n`CrmState` is a hand-built normalised store:\n\n[source,typescript]\n----\ninterface CrmStateModel {\n  crmItems: { [uuid: string]: CrmItem };   // the entity map\n  all?: string[];                          // ordered uuid lists — the \"streams\"\n  pinnedNotes?: string[];\n  notes?: string[];\n  emails?: string[];\n  batchEmails?: string[];\n  tasks?: string[];\n  events?: string[];\n  files?: string[];\n}\n\ninterface CrmState {\n  targetUuid: string;\n  crmCurrentState: CrmStateModel;                      // the active target\n  crmStatesList: { [targetUuid: string]: CrmStateModel }; // per-target cache\n  crmPendingItems: { [key: string]: CrmItemStatus };   // in-flight mutations\n}\n----\n\nSelectors are generated per stream by a factory:\n\n[source,typescript]\n----\nexport const selectCrmAll         = createTimelineStreamSelector('all');\nexport const selectCrmNotes       = createTimelineStreamSelector('notes');\nexport const selectCrmFiles       = createTimelineStreamSelector('files');\nexport const selectCrmTasks       = createTimelineStreamSelector('tasks');\nexport const selectCrmEvents      = createTimelineStreamSelector('events');\nexport const selectCrmEmails      = createTimelineStreamSelector('emails');\nexport const selectCrmBatchEmails = createTimelineStreamSelector('batchEmails');\nexport const selectCrmPinnedNotes = createTimelineStreamSelector('pinnedNotes');\n----\n\n=== Reducer behaviour\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | Behaviour\n\n| `SET_TARGET_UUID`\n| Switches `crmCurrentState` to the cached state for that target, creating an empty\n  one if absent. Dispatched automatically by\n  `SupplierEffects.navigateToSupplier` alongside the supplier load.\n\n| `UPDATE_CRM_MODEL`\n| Merges per key. For array values it *concatenates and de-duplicates* (append\n  semantics, for paging); for object values it spreads. `alterKeys` names the keys\n  that should be *replaced* instead of merged.\n\n| `ADD_CRM_MODEL`\n| Inserts into `crmItems` and `unshift`s the uuid onto each stream in\n  `updateStreams` — new items appear at the top.\n\n| `DELETE_CRM_MODEL`\n| Removes from `crmItems` and from `all`, `notes`, `tasks`, `events`, `emails`,\n  `pinnedNotes`.\n\n| `EDIT_CRM_NOTE` / `PIN_CRM_NOTE`\n| Marks `crmPendingItems[uuid] = PENDING`.\n\n| `CRM_ITEM_RESPONSE_SUCCESS` / `_ERROR` / `CRM_ITEM_CLEAR_STATUS`\n| Set and clear `crmPendingItems[actionId]` with `CrmItemStatus`\n  (`SUCCESS` / `ERROR` / `PENDING`).\n\n| `CRM_CLEAR_CURRENT_STATE`\n| Resets only the active target's state.\n|===\n\n[WARNING]\n====\n`ADD_CRM_MODEL` and `DELETE_CRM_MODEL` mutate `stateToUpdate.crmItems` and\n`stateToUpdate[stream]` in place before spreading the surrounding object. The\n`crmStatesList` entry and `crmCurrentState` can therefore reference the same mutated\ninner objects. Do not rely on reference identity of a stream array to detect change.\n\n`DELETE_CRM_MODEL` also omits `files` and `batchEmails` from its cleanup, so deleting a\nfile leaves its uuid in the `files` stream pointing at a removed item.\n====\n\n== Item types\n\nA single `activity/` feed returns mixed types, dispatched on `item.type` in\n`CrmService.listActions`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `type` | Model\n\n| `note`\n| `NoteModel`\n\n| `task`\n| `TaskModel`\n\n| `event`\n| `EventModel`\n\n| `file`\n| `FileModel`\n\n| `email`, `email_supplier`\n| `EmailModel`\n\n| `batch`\n| `EmailModel`\n\n| anything else\n| `BaseCrmModel` (graceful degradation)\n|===\n\n`CrmItem` (`models/crm-item.type.ts`) is the union. Write shapes are separate models:\n`NoteDataModel`, `FileDataModel`, `EventDataModel`.\n\nAdding a type means touching the switch, the `CrmItem` union, `CrmStateModel`'s stream\nlist and the `DELETE_CRM_MODEL` cleanup.\n\n== Timeline endpoints\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Notes\n\n| `GET activity/`\n| The combined feed. `limit` (default 5) and `last` for cursor paging.\n\n| `GET notes/`, `GET files/`\n| Per-type feeds via a shared `listActionsBase<T>` helper, default `limit` 10.\n\n| `GET es-changelog` (event logger)\n| The event stream. **Requests `limit = 2000000`** with a\n  `// todo - ugly fix - check and fix it later` comment — there is no real paging on\n  events.\n\n| `POST`/`PUT`/`DELETE notes/…`\n| Note CRUD, including pinning.\n\n| `POST`/`PUT`/`GET`/`DELETE tasks/…`\n| Task CRUD — see below.\n|===\n\nPaging uses an opaque `last` cursor, JSON-stringified into a query parameter, with the\nnext cursor returned in `CrmMetaModel`. Infinite scroll uses\n`ngx-infinite-scroll` and appends via `UPDATE_CRM_MODEL`'s de-duplicating merge.\n\n== Tasks\n\n`TasksService` on `actionsToolsUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET tasks/`\n| List with filters.\n\n| `POST tasks/`\n| Create (`Serialize(data, TaskModel)`).\n\n| `GET tasks/:uuid/`\n| Read.\n\n| `PUT tasks/:uuid/`\n| Update.\n\n| `DELETE tasks/:uuid/`\n| Delete one.\n\n| `DELETE tasks/` with `{ uuids: [...] }` body\n| Bulk delete — issued via `httpClient.request('delete', …, { body })`.\n\n| `PATCH tasks/`\n| Bulk partial update.\n\n| `GET tasks/:uuid/events/`\n| A task's own event history.\n|===\n\n=== Status and priority\n\n[source,typescript]\n----\nexport enum TaskStatuses  { NEW = 'new', IN_PROGR = 'in-progr', DONE = 'done', CLOSED = 'closed' }\nexport enum TaskPriorities { LOW = 'low', MEDIUM = 'medium', HIGH = 'high', CRITICAL = 'critical' }\n----\n\nBoth ship with parallel display arrays (`taskStatuses`, `taskPriorities`) mapping key\nto label — note `IN_PROGR` displays as **\"Started\"**, not \"In progress\".\n\n`tasks/task-next-status.directive.ts` implements the forward transition, so status\nprogression is encoded in a directive rather than a state machine.\n\n=== Task save hooks\n\n`CRM_BEFORE_TASK_SAVE` and `CRM_AFTER_TASK_SAVE` bracket a task save. They exist so\nthat other parts of the app can react to a task changing without coupling to the save\ncall — for example refreshing counters.\n\n== Task filters and counters\n\n`FilterSet` (`models/task-filter.model.ts`) describes a named filter:\n\n[source,typescript]\n----\nclass FilterSet {\n  title: string;\n  isGroup? = false;\n  count?: number;\n  filters?:      { [key: string]: any };   // UI representation\n  filtersQuery?: { [key: string]: any };   // query representation\n  subFilters?: any[];\n  permCode?: USER_PERMISSION | string;      // filter visible only with this permission\n  membersCount?: number;\n}\n----\n\n`FilterDefinition` splits them into `predefined` and `custom`. The `/tasks` route takes\na `:filterId` (default `0`), so a filter selection is a URL, and therefore\nbookmarkable and shareable.\n\n`TasksFiltersService` hits `tasks/filters/counts/` and\n`tasks/filters/counts/assignees/`.\n\n`TaskCountBadgeComponent` (`shared/task-count-badge/`) renders the sidebar counters\nwith `filterType` of `currentUser` or `currentUserGroup`. Its behaviour:\n\n* Reloads every `RELOAD_COUNT_INTERVAL = 60000` ms.\n* Listens to `document` `visibilitychange` and only reloads while the tab is visible.\n* Also reloads on the `TASK_COUNTERS_UPDATE` event from `TableEventsService`, which\n  `CrmEffects` broadcasts after a task mutation.\n* Consults `FeatureFlagsService` — `TASKS_MID_VER_SIMPLE_QUERY` switches the counting\n  query.\n* `ViewEncapsulation.None`, so its styles are global.\n\n== Notes, files, comments, emails\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Item | Detail\n\n| Notes\n| `notes/` plus `actions-list-pinned/`. Pinning is a note-level flag\n  (`PIN_CRM_NOTE`), with a dedicated `pinnedNotes` stream. Bulk note import is\n  available under Manage — see xref:manage.adoc#bulk[bulk actions].\n\n| Files\n| `files/` with `file-types.service.ts`, `file-type.model.ts`,\n  `file-type-category.model.ts`. Delete carries both a `uuid` and a `b2bUuid`, because\n  the file record and the document blob live in different systems.\n\n| Comments\n| `comments/` with `comments.service.ts` — comments *on* CRM items, distinct from\n  notes.\n\n| Emails\n| `emails/` with `emails.service.ts` and `emails-status.service.ts`. Single email\n  (`SEND_SINGLE_EMAIL`) and batch email are separate streams. See\n  xref:emails.adoc[emails].\n\n| Events\n| `events/` — read-only system events from the event logger. See\n  xref:platform:events.adoc[events].\n\n| B2B preview\n| `crm-tools/shared/b2b-preview/` renders an attached document inline.\n\n| Attachment base\n| `crm-tools/shared/item-with-attachment.component.ts`.\n|===\n\n`action-tab-calculator.service.ts` (on `internalApiUrl`) computes the per-tab counts\nshown on the CRM panel's tab strip.\n\n== CRM panel presentation\n\nThe panel has four states — `HIDDEN`, `OPEN`, `CLOSED`, `FIXED` (`CrmStates`) — held in\nthe `layout` slice and defaulted per route pattern: fixed on the supplier General tab,\nclosed on other supplier tabs, hidden on the supplier list. See\nxref:architecture:ui-composition.adoc[UI composition].\n\n`CRM_OPEN_ALL_TAB` on `TableEventsService` lets effects switch the panel to the \"All\"\ntab after adding an item.\n\n== Access\n\n`/tasks` and `/emails` both deny `REST_UW_ACCESS` and `ADVANCE_AUDITORS`, and the\nsidebar hides the task counters for those groups. `CRM_ADMIN` is the elevated CRM\npermission. Filters can carry their own `permCode`, so a filter can be hidden from\nusers who could otherwise see the list.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Implicit target scoping\n| The target UUID travels as a header set from a store subscription, not as a\n  parameter.\n\n| Event paging is a `limit=2000000`\n| Acknowledged debt in the source.\n\n| `DELETE_CRM_MODEL` misses two streams\n| `files` and `batchEmails` are not cleaned up.\n\n| In-place mutation\n| `ADD_CRM_MODEL` / `DELETE_CRM_MODEL` mutate nested state.\n\n| `IN_PROGR` displays as \"Started\"\n| Do not \"correct\" the label without checking the training material.\n\n| Bulk delete uses a request body\n| `DELETE tasks/` with `{ uuids }`.\n\n| Pending statuses must be cleared\n| `CRM_ITEM_CLEAR_STATUS` after handling; otherwise a stale status persists for that\n  `actionId`.\n\n| `crmItems` starts `null`\n| `initialState().crmCurrentState.crmItems` is `null`, not `{}`. `ADD_CRM_MODEL`\n  initialises it lazily; other readers must guard.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:emails.adoc","title":"Emails and messaging","description":"Outbound email — the log, preview and approval workflow, batch sends, templates and per-supplier preferences.","url":"https://docx.payability.com/internal-admin/domains/emails.html","navOrder":29,"source":"= Emails and messaging\n:description: Outbound email — the log, preview and approval workflow, batch sends, templates and per-supplier preferences.\n\n== What it is\n\nEvery email Payability sends to a supplier passes through the messaging service and is\nvisible here. Emails are not fire-and-forget: many are *queued pending approval*, can\nbe previewed as rendered HTML, approved or cancelled individually or as a batch, and\ncarry their own event history.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `emails/emails-routing.routes.ts` — `/emails/list` and\n  `/emails/list/:filterId/filter`. The `emails/` module contains *only* routes.\n\n| Components\n| `crm-tools/emails/` — `email-list/`, `email-preview/`, `email-types.ts`\n\n| Service\n| `crm-tools/services/emails.service.ts` (`messagingUrl`) with\n  `emails-status.service.ts` and `emails-mock.service.ts`\n\n| Model\n| `crm-tools/models/email.model.ts`, `batch.model.ts`\n\n| Settings\n| `/manage/email-config/list` → `manage/email-settings/`\n\n| Supplier preferences\n| `suppliers/services/supplier-email-preferences.service.ts`,\n  `suppliers/model/email-preference.model.ts`,\n  `suppliers/supplier/supplier-email-preferences/`\n|===\n\n[NOTE]\n====\n`/emails/list/:filterId/filter` renders `EmailPreviewComponent`, while\n`/emails/list` renders `EmailListComponent`. The route *name* says \"filter\" but the\ncomponent is the preview — the parameterised route is used to open a specific email.\n====\n\n== Email types\n\n`crm-tools/emails/email-types.ts`:\n\n[cols=\"2,2,1,3\",options=\"header\"]\n|===\n| Enum member | Wire value | In filters | Meaning\n\n| `EMAIL_SUPPLIER`\n| `email_supplier`\n| yes\n| Sent manually from a supplier page.\n\n| `NOTE_EMAIL`\n| `email_note`\n| yes\n| Generated from a CRM note.\n\n| `BATCH_GROUP`\n| `batch group`\n| yes\n| A group of batch emails (the container).\n\n| `AUTO_EMAIL`\n| `email`\n| yes\n| Automatic email, typically fired by an event rule.\n\n| `TASK_NOTIFICATION`\n| `task notification`\n| yes\n| Task assignment/change notification.\n\n| `BATCH_EMAIL`\n| `batch`\n| **no**\n| An individual email inside a batch group — deliberately excluded from the filter\n  list so that batches appear once, as their group.\n|===\n\n[CAUTION]\n====\nThe wire values are inconsistent: `AUTO_EMAIL` is the bare string `'email'`,\n`BATCH_GROUP` contains a *space* (`'batch group'`). Do not derive a wire value from the\nenum name.\n====\n\nDefault filter selection (`EmailsStatusService.getDefaultFiltersType`) is\n`AUTO_EMAIL`, `NOTE_EMAIL`, `EMAIL_SUPPLIER`, `BATCH_GROUP` — so task notifications are\nhidden until explicitly requested.\n\n== Statuses\n\n[source,typescript]\n----\nexport enum EmailStatuses { SENT = 'sent', PENDING = 'pending', CANCELLED = 'cancelled', SENDING = 'sending' }\n----\n\n`PENDING` means *awaiting operator approval*, not \"queued for delivery\" — that is\n`SENDING`. `EmailsStatusService` provides the predicates (`isPending`, `isSend`,\n`isBatchGroup`) and the type-name lookup; use them rather than comparing strings.\n\nAn `EmailActions` enum in the same file names the operations (`approve`, …).\n\n== Endpoints\n\nAll on `messagingUrl`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET email/`\n| The email log.\n\n| `POST emails/`\n| Create/queue an email (`Serialize(data, EmailModel)`).\n\n| `GET emails/:uuid/`\n| One email.\n\n| `PUT emails/:uuid/`\n| Update a queued email.\n\n| `DELETE emails/:uuid/`\n| Delete.\n\n| `POST emails/batch-delete/` with `{ uuids }`\n| Bulk delete.\n\n| `GET emails/:uuid/events/`\n| Delivery/lifecycle events for an email.\n\n| `POST email/rendered/`\n| Render the email to final HTML.\n\n| `POST email/preview/`\n| Render a preview.\n\n| `GET email/approve/:emailUuid`\n| Approve one email.\n\n| `GET email/cancel/:emailUuid`\n| Cancel one email.\n\n| `GET email/batch_approve/:batchUuid`\n| Approve a whole batch.\n\n| `GET email/batch_cancel/:batchUuid`\n| Cancel a whole batch.\n\n| `POST email/batch/`\n| Create a batch send.\n|===\n\n[WARNING]\n====\nApprove and cancel are `GET` requests that *mutate state*. They are not safe or\nidempotent in the HTTP sense. Do not prefetch them, do not put them behind a link\ncrawler, and be careful adding them to any retry logic.\n====\n\n`emails.service.ts` also carries its own `headers` object (instance id, target UUID)\nin the same style as `CrmService`.\n\n== Two render paths\n\n`email/rendered/` and `email/preview/` both produce HTML from an `EmailModel`. `preview`\nis the operator-facing \"what will this look like\" call; `rendered` produces the final\nartefact. Both take the full model, so a preview reflects unsaved edits.\n\n`crm-tools/shared/b2b-preview/` and the TinyMCE editor (`shared/editor/`) are the\ncomposition surface.\n\n== Batch email\n\nA *batch group* is a single logical send to many recipients:\n\n* `POST email/batch/` creates it.\n* It appears in the timeline as `BATCH_GROUP`; the individual `BATCH_EMAIL` items hang\n  off it.\n* `email/batch_approve/:batchUuid` and `email/batch_cancel/:batchUuid` act on the whole\n  group.\n* The CRM store keeps a separate `batchEmails` stream, loaded by\n  `FETCH_CRM_BATCH_EMAILS` and read through `selectCrmBatchEmails`.\n\nBulk email *creation* from a CSV is a Manage operation — see\nxref:manage.adoc#bulk[bulk actions].\n\n== Email settings (Manage)\n\n`/manage/email-config/list` administers templates and configuration.\n`manage/email-settings/`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Folder | Purpose\n\n| `email-settings-list/`\n| The configuration list.\n\n| `email-add-new-config-dialog/`\n| Add a configuration.\n\n| `email-edit-config-dialog/`\n| Edit a configuration.\n\n| `email-edit-variables/`\n| Edit the template variables available to that email.\n\n| `delete-email-setting/`\n| Delete.\n\n| `email-type-badge/`\n| The type badge shown in the list.\n\n| `email-form-fields.ts`\n| The form field definitions.\n|===\n\nGated by the `INT_EMAIL_SETTINGS` permission.\n\n== Per-supplier email preferences\n\nA supplier can opt out of categories of email.\n`SupplierEmailPreferencesService` on `internalApiUrl`:\n\n[source]\n----\nGET suppliers/:supplierKey/email_preference   → { emailPreference: EmailPreference[] }\nPUT suppliers/:supplierKey/email_preference   ← { emailPreferenceCode, isEnabled }\n----\n\n[source,typescript]\n----\nexport interface EmailPreference {\n  code: string;         // e.g. \"DAILY_EMAILS\"\n  name: string;\n  description: string;\n  enabled: boolean;\n}\n----\n\nNote the endpoint path is *singular* `email_preference` while the response property is\n`emailPreference` wrapping an array, and the update sends one preference at a time\n(`emailPreferenceCode` + `isEnabled`) rather than the whole set.\n\nThe supplier record also carries a coarse `sendAutoEmail` boolean, independent of these\nper-code preferences.\n\nThis feature has a spec in `.specs/email-preference-management.md` — one of the two\nreference spec formats. See xref:ops:spec-driven-workflow.adoc[the spec-driven workflow].\n\n== Emails as an event-rule action\n\n`send-email` is one of the four active event-rule action types\n(`manage/event-rules/rules-add-edit/action-form/send-email/`). A rule matches a domain\nevent, applies filters, and sends a templated email with context and data mappings.\nThat is how most `AUTO_EMAIL` traffic originates. See\nxref:platform:events.adoc[events].\n\n== SMS\n\nText messaging shares the messaging backend but not this UI.\n`suppliers/supplier/supplier-onboarding/smsnumbers/sms-numbers-provider.service.ts`\nspans `authToolsApiUrl` (provisioning the number) and `messagingUrl` (sending). See\nxref:supplier-onboarding.adoc[onboarding].\n\n== Access\n\n`/emails` denies `REST_UW_ACCESS` and `ADVANCE_AUDITORS`, both on the route and in the\nsidebar. `INT_EMAIL_SETTINGS` governs the Manage screens.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| `GET` mutates\n| Approve and cancel, single and batch.\n\n| Enum values are not derivable\n| `'email'` for `AUTO_EMAIL`; `'batch group'` contains a space.\n\n| `PENDING` is approval, not delivery\n| `SENDING` is delivery.\n\n| `BATCH_EMAIL` is filtered out by design\n| Batches surface as `BATCH_GROUP`.\n\n| The `emails/` module has no components\n| It is a routing shim over `crm-tools/emails/`.\n\n| Route name/component mismatch\n| `list/:filterId/filter` renders the preview component.\n\n| Two email models share one class\n| `EmailModel` is used for `email`, `email_supplier` *and* `batch` items in\n  `CrmService.listActions`.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:reports.adoc","title":"Reports","description":"Configuration-driven internal reports over database views, external report feeds, the waiting-suppliers report, and CSV export.","url":"https://docx.payability.com/internal-admin/domains/reports.html","navOrder":30,"source":"= Reports\n:description: Configuration-driven internal reports over database views, external report feeds, the waiting-suppliers report, and CSV export.\n\n== What it is\n\nThree different things called \"reports\":\n\n*Internal reports*::\nA configuration record that names a database view or table plus a column list. Adding a\nreport is a *configuration* change, not a code change — an administrator points a\nreport at a view, chooses columns, sets types and filterability, and it becomes a\nroute.\n\n*External reports*::\nFixed feeds from third parties (Plaid bank transactions, bank categorisation, ML\nprediction sets), fetched by report key.\n\n*Purpose-built reports*::\nHand-written screens, currently just the waiting-suppliers report.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `reports/reports-routing.routes.ts`\n\n| Components\n| `reports/report-list/`, `report-add/`, `report-edit/`, `report-table/`,\n  `external-report-list/`, `waiting-suppliers/`\n\n| Services\n| `reports/service/reports-repository.service.ts` (implements\n  `ReportsAbstractService`), `reports/service/external-reports.service.ts`\n\n| Models\n| `reports/model/` — `report-model.ts` (incl. `EditableReportModel`,\n  `EditableReportFieldModel`), `report-list-model.ts`, `report-common.ts`,\n  `external-report.ts`, `i-external-report.ts`; `reports/dto/external-report-dto.ts`\n\n| Table config\n| `reports/report-table/report-table.interfaces.ts`\n\n| Pipes\n| `reports/pipes/report.pipe.ts`\n\n| Shared\n| `shared/external-report/`, `shared/csv-export-service/`\n\n| Fixtures\n| `reports/mock/reports.mock.json`, `tables.mock.json`\n|===\n\n`ReportsRepositoryService` is provided in `AppModule`, because report configuration is\nconsumed outside the reports module — notably by the supplier statements tab.\n\n== Routes\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Screen\n\n| `/reports/list`\n| `ReportListComponent` — all configured reports.\n\n| `/reports/external`\n| `ExternalReportListComponent`.\n\n| `/reports/waiting-suppliers`\n| `WaitingSuppliersReportComponent`.\n\n| `/reports/new`\n| `ReportAddComponent`.\n\n| `/reports/:reportKey/edit`\n| `ReportEditComponent`.\n\n| `/reports/:reportName`\n| `ReportTableComponent`, with\n  `data: { supplierKeyInQueryParamsNotNeeded: true }` — renders any configured\n  report by name.\n|===\n\n[CAUTION]\n====\nRoute ordering matters here. `:reportName` is the last child, so the literal paths\n(`list`, `external`, `new`, `waiting-suppliers`) win. A new report configured with the\nname `list` or `new` would be unreachable. Adding a literal route *after* `:reportName`\nwould make it unreachable.\n====\n\nAll reports routes deny `REST_UW_ACCESS` and `ADVANCE_AUDITORS`. `INT_REP_ADMIN` is the\npermission for report administration.\n\n== Internal report configuration\n\n`ReportsRepositoryService` on `internalApiUrl` under `reports`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET reports/config`\n| All report configurations → `ReportListModel[]`.\n\n| `GET reports/config/:uuid`\n| One configuration → `EditableReportModel`.\n\n| `POST reports/config`\n| Create.\n\n| `PUT reports/config/:uuid`\n| Update.\n\n| `DELETE reports/config/:uuid`\n| Delete.\n\n| `POST reports/config/:uuid/overrides`\n| Add a field override (`EditableReportFieldModel`) — per-column customisation.\n\n| `GET reports/db_objects`\n| The available database views and tables — the source list for the add form.\n\n| `GET reports/:uuid`\n| A report record (`ReportModel`).\n\n| `PUT reports/:uuid`\n| Update a report record.\n|===\n\n=== Deriving a table configuration\n\n`getReportConfiguration(reportName)` is the bridge from configuration to grid. It\nfetches *all* configurations, filters client-side by `name`, and reshapes:\n\n[source,typescript]\n----\n{\n  title:     config.label || config.name,\n  reportKey: config.report_key,\n  fields: config.fields.map((column) => {\n    const name = Object.keys(column)[0];   // each field is a single-key object\n    const obj  = column[name];\n    return { name, show: true, filterable: obj.filterable, type: obj.type };\n  }),\n}\n----\n\nTwo things to note: the client filters the full config list rather than requesting one\nby name, and each field arrives as a *single-key object* whose key is the column name —\nhence the `Object.keys(column)[0]` idiom.\n\n=== Column types\n\n`report-table.interfaces.ts`:\n\n[source,typescript]\n----\nexport enum ReportColumnType {\n  Date     = 'timestamptz',   // the PostgreSQL type name\n  Text     = 'text',\n  Currency = 'currency',\n}\n\nexport interface ReportColumn {\n  data: string;\n  editor: boolean;              // is the column editable in place\n  type: Date | string;\n  filter?: { label: string };\n  cellRenderer?: (cellData: any) => string;\n}\n\nexport interface ReportTableConfiguration { title: string; columns: ReportColumn[]; }\n----\n\n`Date = 'timestamptz'` shows the leak: column types are database types, not display\ntypes. `currency` is the exception — a presentation type with no direct SQL equivalent.\n\n`editor: true` makes a report column *editable*, writing back through the report\nendpoints. A report is therefore not necessarily read-only.\n\n=== Serialisation\n\nReport configuration is `snake_case` on the wire, and the service sets the casing\nper call: `DeserializeKeysFrom(UnderscoreCase)` before reads,\n`SerializeKeysTo(UnderscoreCase)` before writes. See\nxref:architecture:data-modeling.adoc[data modelling] for why the ordering matters.\n\n== Running a report\n\n`SupplierService.getReport(reportName, options)` is the query path — note it lives on\nthe *supplier* service, not the reports service:\n\n[source]\n----\nGET reports/:reportName?<options as query params>\n----\n\nwith two behaviours:\n\n* Every key in `options` becomes a query parameter.\n* If `options.make_dump` is truthy the request is issued with\n  `responseType: 'text'` — the dump path returns raw text rather than JSON, for\n  export.\n\n`toLowercaseUnderscore` (`shared/utils/`) converts display labels to column names when\nbuilding queries.\n\n`supplierKeyInQueryParamsNotNeeded` in route data suppresses the otherwise-required\nsupplier-key parameter — used by `/reports/:reportName` and by the supplier statements\ntab.\n\n== Supplier-scoped reports\n\nTwo surfaces render reports inside the supplier workspace:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Surface | Detail\n\n| `/suppliers/:key/reports/{list,:reportName}`\n| `SupplierReportsComponent` with `supplier-report-list/` and\n  `supplier-report-details/` — the same report machinery scoped to one supplier.\n\n| `/suppliers/:key/statements`\n| Rendered from route data:\n  `reportName: 'mp_statements'`, `reportConfigName: 'v_mp_statements'`. The\n  marketplace statements \"tab\" is a report over a view. See\n  xref:marketplaces.adoc[marketplaces].\n|===\n\n`SupplierService` also exposes `supplierKeyColumnName = 'supplier_key'` and\n`supplierDepositTransactionColName = 'supplier_deposit_transactions'` — column names the\nreport layer needs when scoping to a supplier.\n\n== External reports\n\n`ExternalReportsService` on `internalApiUrl`:\n\n[source]\n----\nGET reports/external_list          → IExternalReport[]\nGET reports/external/:reportKeys   → IExternalReport[]\n----\n\nKeys are the `ExternalReports` enum\n(`shared/external-report/external-reports.enum.ts`):\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Member | Key | Content\n\n| `PLAID`\n| `PLAID-D`\n| Bank transactions via Plaid.\n\n| `BANK_CATEGORIES`\n| `PL-BT`\n| Categorised bank transactions.\n\n| `ACCESS_ONBOARDING_PREDICTIONS`\n| `BR-AC-ON`\n| ML predictions at onboarding.\n\n| `ACCESS_DAILY_PREDICTIONS`\n| `BR-AC-DL`\n| ML predictions recomputed daily.\n|===\n\n`/reports/external` lists them; the same four appear as underwriting tabs, each behind\nits own feature flag. See xref:supplier-underwriting.adoc[underwriting].\n\nThe detail endpoint takes `reportExternalKeys` (plural) — multiple keys in one call.\n\n== Waiting suppliers\n\n`reports/waiting-suppliers/waiting-suppliers.component.ts` is the odd one out: it calls\n`actionsToolsUrl` directly *from the component*, not through a service. It reports\nsuppliers waiting on some action.\n\n[NOTE]\n====\nThis is the only place in the reports module that talks to Actions Tools, and one of\nvery few components in the app that issue HTTP directly. Do not copy the pattern; if\nyou extend it, extract a service.\n====\n\n== CSV export\n\n`shared/csv-export-service/csv-export.service.ts`:\n\n[source,typescript]\n----\nexportCsv(data, filename)                    // Blob 'text/csv;charset=utf-8' → file-saver\nexportTransactions(data, filename, type)     // filename_MMDDYYYY[type].csv\n----\n\n`dataToString` concatenates only the *string* values of the input object, dropping\nnumbers and nested values.\n\n[CAUTION]\n====\n`dataToString` silently discards non-string values, so `exportTransactions` expects\ndata that has already been stringified. Passing raw records produces a truncated file\nwith no error. Use `exportCsv` with a prepared CSV string when in doubt.\n====\n\nFor large server-side exports, prefer the `make_dump` path on `getReport`, which\nreturns text from the backend rather than assembling it in the browser.\n\nAG Grid's own export is also available on grids built with\n`shared/pyb-ag-grid/` — see xref:architecture:ui-composition.adoc#tables[table\ngenerations].\n\n== Adding a report\n\n. `/reports/new` → choose a database object from `reports/db_objects`.\n. Select columns, set `type` and `filterable` per column, and `editor` if the column\n  should be editable.\n. Save → `POST reports/config`.\n. The report is immediately reachable at `/reports/<name>` and appears in\n  `/reports/list`.\n. Avoid names that collide with the literal child routes (`list`, `external`, `new`,\n  `waiting-suppliers`).\n\nNo deployment is required. This is the main reason report changes are handled by\nadministrators rather than developers.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Query path lives on `SupplierService`\n| `getReport` is not on the reports service.\n\n| Whole-config fetch to resolve one report\n| `getReportConfiguration` filters client-side.\n\n| Single-key field objects\n| `Object.keys(column)[0]` is the column name.\n\n| Column types are SQL types\n| `timestamptz` for dates.\n\n| `:reportName` is a catch-all\n| Name collisions with literal routes make a report unreachable.\n\n| `dataToString` drops non-strings\n| See the caution above.\n\n| Direct HTTP in `WaitingSuppliersReportComponent`\n| Bypasses the service layer.\n\n| Reports can write\n| `editor: true` columns are editable.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:manage.adoc","title":"Manage (back-office configuration)","description":"The administration area — bulk imports, users and permissions, global settings, configs, onboarding templates, contracts, fee settings, month-end close and the event stream.","url":"https://docx.payability.com/internal-admin/domains/manage.html","navOrder":31,"source":"= Manage (back-office configuration)\n:description: The administration area — bulk imports, users and permissions, global settings, configs, onboarding templates, contracts, fee settings, month-end close and the event stream.\n\n== What it is\n\nEverything that configures the rest of the application. ~79 components, all under\n`/manage`, with the parent route denying the two restricted groups\n(`REST_UW_ACCESS`, `ADVANCE_AUDITORS`) and per-screen permissions enforced inside.\n\nThe default child route is `users/list`.\n\n== Screen map\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Route | Folder | Purpose\n\n| `manage/batch-imports`\n| `batch-imports/`\n| Bulk CSV imports and CLI actions. See xref:#bulk[bulk actions].\n\n| `manage/users/{list,new,:loginKey/edit}`\n| `users/`\n| Internal user administration.\n\n| `manage/perm-groups/{list,new,:permGroupKey/edit}`\n| `perm-groups/`\n| Permission groups.\n\n| `manage/global-settings`\n| `global-settings/`\n| Key/value configuration store. Has its own state slice.\n\n| `manage/configs`\n| `configs/`\n| Typed configuration records (task templates, email templates, …).\n\n| `manage/event-rules/{list,add,:eventRuleId/edit,:eventRuleId/history}`\n| `event-rules/`\n| Event rules v1. See xref:platform:events.adoc[events].\n\n| `manage/event-rules-2`\n| `event-rules-2/`\n| Event rules v2 (beta).\n\n| `manage/events`\n| `events/`\n| The raw domain-event stream with badges, filters and preview.\n\n| `manage/onboarding/{list,add,:onboardingTemplateKey/edit,:onboardingTemplateKey/marketplaces}`\n| `onboarding/`\n| Onboarding templates. See\n  xref:supplier-onboarding.adoc[onboarding].\n\n| `manage/email-config/list`\n| `email-settings/`\n| Email templates and configuration. See xref:emails.adoc[emails].\n\n| `manage/fee-settings/{list,add,:id/edit}`\n| `fee-settings/`\n| Fee schedules and tiers. See\n  xref:fees-and-reserves.adoc[fees and reserves].\n\n| `manage/contracts`\n| `contracts/`\n| Contract templates.\n\n| `manage/month-end-close`\n| `month-end-close/`\n| The accounting close screen.\n\n| —\n| `permission-error-pop-up/`\n| Shared \"access denied\" dialog used across the app.\n|===\n\n[[bulk]]\n== Bulk actions and CLI\n\n`/manage/batch-imports` is the bulk-operations console. `BatchImportService` is the\nonly service in the codebase that spans *four* backends — `internalApiUrl`,\n`cliApiUrl`, `actionsToolsUrl` and `b2bDocumentsApiUrl`.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Import | Endpoint | Notes\n\n| Manual bank accounts\n| `POST internalApiUrl deposit_accounts/csv_upload`\n| `deposit-accounts/` — CSV of supplier bank accounts.\n\n| Pre-funded cards\n| `POST internalApiUrl payment/configs/csv_upload`\n| `pre-funded-cards/`.\n\n| Bulk notes\n| `POST actionsToolsUrl notes/bulk-update/`\n| `bulk-notes/`. Parsed *in the browser* — see below.\n\n| EFS upload\n| `POST cliApiUrl efs_csv_upload`\n| Card programme data.\n\n| Supplier credit\n| `POST internalApiUrl supplier_credit`\n| `supplier-promos/`, `supplier-rebates/`.\n\n| Weekly purchases\n| `POST cliApiUrl purchase_rcv` (empty body)\n| `weekly-purchases/` — triggers a job rather than uploading data.\n\n| UCC upload\n| `ucc-upload/`\n| Bulk UCC filing data. See\n  xref:supplier-underwriting.adoc[underwriting].\n\n| CLI actions\n| `cli-action/` with `cli-action.service.ts` (+ mock)\n| Arbitrary CLI operations exposed as forms.\n|===\n\n=== Client-side CSV parsing\n\n`getNotes(data, user)` reads the file with `FileReader` and builds note records in the\nbrowser:\n\n[source,typescript]\n----\nconst lines = e.target.result.split('\\n');\nfor (let i = 1; i < lines.length; i++) {        // skips the header row\n  const line = lines[i].split(',');             // naive comma split\n  if (line[0]) {\n    notes.push({ target_type: 'note', target_uuid: line[0],\n                 body: line[1], is_pinned: line[2] === 'true', user });\n  }\n}\n----\n\n[WARNING]\n====\nThis is a naive split on `,` with no quote handling. A note body containing a comma is\nsilently truncated at the comma, and any further columns shift. Column order is fixed:\n`target_uuid, body, is_pinned`. The first line is always discarded as a header.\n====\n\n=== Job monitoring\n\nLong-running imports are executed by the B2B task manager, and progress is polled or\nstreamed:\n\n[source,typescript]\n----\ngetLastExecution(name)     → B2bWorkflowService.getLastExecution(name)\ngetListOfActivities(name)  → getLastExecution(name) then getListOfActivities(task.id)\n----\n\nThe underlying stream is server-sent events. See\nxref:platform:realtime.adoc[real-time streams].\n\n== Users\n\n`UsersService` on `internalApiUrl` (`logins` and `perms`):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET logins?permCode=…[&search=…]`\n| Users filtered by permission code. `getUsers` always filters on\n  `INT_LOGIN`.\n\n| `GET logins/:loginKey`\n| One user → `UserModel`.\n\n| `PUT logins/:loginKey`\n| Update.\n\n| `DELETE logins/:loginKey`\n| Delete.\n\n| `GET logins/:loginKey/perms`\n| A user's permissions.\n\n| `GET perms/:permCode/sub-perms`\n| Sub-permissions of a permission (cached in an instance field after first call).\n|===\n\n`getUser(username, jwtToken)` is the call `AuthEffects` makes at login to load the user\nand their `perms` — see xref:platform:authentication.adoc[authentication].\n`checkPermission(permCode)` is what `SubPermissionsGuard` uses.\n\n=== The name-field hack\n\n`saveLogin` contains an explicit, commented workaround:\n\n[source,typescript]\n----\nconst payload = {\n  ...Serialize(data, UserModel),\n  firstName: data.firstname,  lastName: data.lastname,\n  firstname: undefined,       lastname: undefined,\n};\n// sorry for this hack but I don't want to broke other places where we use this model\n----\n\n`UserModel` uses `firstname`/`lastname`; the update endpoint expects\n`firstName`/`lastName`. Rather than change the model (used widely), the service rewrites\nthe payload. Know this before \"fixing\" the model casing.\n\n=== Caching\n\n`UsersService` keeps three `BehaviorSubject` caches — `usersSubject`, `groupsSubject`,\n`usersAndGroupsSubject` — exposed as `users`, `groups` and\n`getPreparedCombinedList()`. The combined list is fetched once with\n`combineLatest([getUsers(), getGroups()])` and cached; there is no invalidation, so a\nuser created in this session will not appear in an assignee dropdown until reload. See\nxref:architecture:state-management.adoc[state management].\n\n== Permission groups\n\n`/manage/perm-groups` with `perm-groups-list/`, `-add/`, `-edit/`.\n`PermissionModel` / `PermissionBaseModel` (`shared/users/permission.model.ts`) and\n`UserPermModel` (`user-perm.model.ts`) are the models; the vocabulary is\n`USER_PERMISSION` (`shared/users/user-permissions.enum.ts`).\n\nGroups nest: a permission has *sub-permissions*\n(`perms/:permCode/sub-perms`), which is what `requireOneOfSubPermissions` route data\nchecks. Requires `INT_PERMS_ADM`. See\nxref:platform:authorization.adoc[authorisation].\n\n== Global settings\n\nThe most load-bearing screen in Manage: it is where feature flags are stored.\n\n`GlobalSetting` (`manage/global-settings/models/global-setting.model.ts`):\n\n[source,typescript]\n----\ninterface GlobalSetting {\n  config_key: string; config_type: string; config_value: any;\n  created_at: number; updated_at: number;\n  instance_id: number;\n  owner_email: string; owner_uuid: string;\n  updated_by: string; updated_by_name: string;\n  uuid: string;\n}\n----\n\n`config_value` is untyped `any` — its shape depends on `config_key`.\n\n`GlobalSettingsService` on `actionsToolsUrl` with an `x-instanceid` header\n(`env.instanceId`, 8001):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET settings/`\n| All settings.\n\n| `GET settings/:configKey/`\n| One setting.\n\n| `GET settings/:type/items`\n| All settings of a type.\n\n| `POST settings/`\n| Create.\n\n| `PUT settings/:configKey/`\n| Update.\n\n| `DELETE settings/:configKey/`\n| Delete.\n|===\n\nThe service is a *facade over the store slice*: `getAll()` selects from\n`globalSettings`, dispatches `GlobalSettingsFetchAll` on a miss or a forced refresh, and\nevery mutation dispatches `GlobalSettingsUpdateConfig`. Call the service; do not select\nthe slice directly from a component.\n\nScreens: `new-setting/`, `edit-setting/`, `delete-setting/`, and `templates/` for\ntemplate-valued settings.\n\nFeature flags live here under the keys `FEATURE_FLAGS_IA` and `FEATURE_FLAGS_EVA`, with\n`config_value = { flags: 'A,B,C', lastChange: <timestamp> }`. See\nxref:platform:feature-flags.adoc[feature flags].\n\n== Configs\n\n`/manage/configs` administers *typed* configuration records. `ConfigsService`\n(`shared/configs/`, with an abstract base and a mock) reads them through\n`GlobalSettingsService` — configs are global settings with a known `config_type`.\n\n`ConfigTypes`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Member | `config_type`\n\n| `TASK_PRIORITIES` | `task_priorities`\n| `TASK_STATUSES` | `task_statuses`\n| `TASK_TEMPLATE` | `task_template`\n| `TASK_GROUPS` | `task_assignee_allowed_groups`\n| `EMAIL_BACK_CONFIG` | `email_config`\n| `EMAIL_FRONT_CONFIG` | `email_frontend_config`\n| `EMAIL_FRONT_BATCH_CONFIG` | `email_frontend_batch_config`\n| `EMAIL_TEMPLATE` | `email_frontend_template`\n| `EMAIL_BASE_TEMPL` | `email_base_template`\n| `EMAIL_FRONTEND_AUTOMATIC_CONFIG` | `email_frontend_automatic_config`\n|===\n\n`ConfigEmailTypes` narrows the list to the six email types, and\n`ConfigEmailTypesNames` pairs each with a display name and a `can_add_new` flag —\nsome email config types are singletons.\n\n[NOTE]\n====\nTask statuses and priorities exist *twice*: as TypeScript enums in\n`crm-tools/tasks/` and as configurable records here. The enums are what the code\nbranches on; the configs drive display and allowed values. A change in one place does\nnot propagate to the other.\n====\n\n== The event stream\n\n`/manage/events` shows raw domain events with `event-badge/`, `event-filters/` and\n`event-preview/`. It is backed by `EventsService`'s Elasticsearch query against the\n`em_events` index on `eventRulesOldUrl`. The sidebar reaches it through\n`navigateToEvents()` rather than a plain `routerLink`.\n\nEvent rules — the reactive layer over the same stream — are documented in\nxref:platform:events.adoc[events].\n\n== Contracts and month-end close\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Screen | Detail\n\n| `/manage/contracts`\n| Contract templates via `LongTermContractsService`\n  (`long_term_contract/template*`). See\n  xref:fees-and-reserves.adoc#contracts[contracts].\n\n| `/manage/month-end-close`\n| A single component (`month-end-close/`) driving the accounting close. Note the\n  sidebar link reuses `data-test=\"sidebarContracts\"` for this item — a copy-paste bug\n  that will confuse Cypress selectors.\n|===\n\n== Permissions\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Permission | Governs\n\n| `INT_PERMS_ADM`\n| Users and permission groups.\n\n| `INT_GLOBAL_SETTINGS`\n| Global settings.\n\n| `INT_EMAIL_SETTINGS`\n| Email settings.\n\n| `INT_RULES_UPDATE`\n| Event rules.\n\n| `INT_REP_ADMIN`\n| Report administration.\n\n| `INT_SUPER_USER`\n| Broad administrative access.\n\n| `CRM_ADMIN`\n| CRM administration.\n\n| `DEV`\n| DEV Admin only — see xref:admin.adoc[DEV Admin].\n|===\n\nThe `/manage` parent route denies `REST_UW_ACCESS` and `ADVANCE_AUDITORS`; finer control\nis per-screen and backend-enforced.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Naive CSV parsing\n| Bulk notes split on `,` with no quoting.\n\n| Four backends in one service\n| `BatchImportService`.\n\n| `firstName` vs `firstname`\n| The documented hack in `saveLogin`.\n\n| Uninvalidated user cache\n| `getPreparedCombinedList()` caches for the session.\n\n| `config_value` is `any`\n| Shape depends on `config_key`; there is no schema.\n\n| Global settings are the flag store\n| Editing `FEATURE_FLAGS_IA` changes behaviour for everyone in the environment.\n\n| Duplicated task vocabularies\n| Enums in code and configs in the database.\n\n| `purchase_rcv` posts an empty body\n| It triggers a job; there is no payload to review before sending.\n\n| Two event-rule generations\n| Both routed and both live.\n|===\n"},{"component":"internal-admin","module":"domains","path":"domains:underwriting-tracker.adoc","title":"Underwriting tracker","description":"The cross-supplier underwriting queue — a fully EVA-rendered screen behind a feature flag.","url":"https://docx.payability.com/internal-admin/domains/underwriting-tracker.html","navOrder":32,"source":"= Underwriting tracker\n:description: The cross-supplier underwriting queue — a fully EVA-rendered screen behind a feature flag.\n\n== What it is\n\nA queue view of underwriting work across *all* suppliers, as opposed to the\nper-supplier underwriting tabs. It answers \"what needs underwriting attention\" rather\nthan \"what is the state of this supplier\".\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Route\n| `/underwriting/tracker` (with `/underwriting` redirecting to it) —\n  `underwriting/underwriting-routing.routes.ts`\n\n| Module\n| `underwriting/underwriting.module.ts` — imports `SharedModule`, declares one\n  component\n\n| Component\n| `underwriting/tracker/tracker.component.ts`\n\n| Feature flag\n| `UNDERWRITING_TRACKER`\n|===\n\n== It is entirely EVA\n\nThis is the clearest example of the micro-front-end seam in the codebase. The component\nhas no logic at all:\n\n[source,typescript]\n----\n@Component({\n  selector: 'app-tracker',\n  templateUrl: './tracker.component.html',\n  styleUrls: ['./tracker.component.scss'],\n})\nexport class TrackerComponent {}\n----\n\n[source,html]\n----\n<div class=\"underwriting-tracker__container\">\n  <app-eva [url]=\"'underwriting-tracker'\"></app-eva>\n</div>\n----\n\nEverything a user sees — the grid, the filters, the actions — is served by *EVA* at\n`$\\{evaApiUrl}underwriting-tracker`, inside an iframe.\n\nWhat Internal Admin still provides:\n\n* *Routing* — the URL, and the route guards\n  (`RouteAccessResolver`, `FeatureFlagsGeneralGuard`, `NgxPermissionsGuard`).\n* *Authentication* — `EvaComponent` reads `localStorage['currentUser']` and hands the\n  JWT to `EvaService`, which either sets a shared-domain cookie or, on `localhost`,\n  appends `?auth=<token>` to the iframe URL.\n* *Authorisation context* — `EvaComponent` resolves the user's sub-permissions via\n  `UsersService.getPermissionSubPerms(permCode)` and sends them to EVA as part of the\n  config message.\n* *Feature flags* — `FeatureFlagsEvaService` computes EVA's flag set and pushes it over\n  `postMessage`.\n* *Chrome* — the sidebar, layout, toasts and iframe resizing.\n\nSee xref:platform:iframe-messaging.adoc[iframe messaging and EVA] for the protocol.\n\n[IMPORTANT]\n====\nBugs reported against \"the UW tracker\" are usually EVA bugs, not bugs in this\nrepository. Before investigating here, establish whether the problem is in the iframe\ncontent (EVA) or in what this app passes to it (token, permissions, flags, size).\n====\n\n== Access\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Gate | Detail\n\n| Feature flag\n| `UNDERWRITING_TRACKER` — checked twice: `FeatureFlagsGeneralGuard` initialises flags\n  on the route, and the sidebar wraps the menu item in\n  `*appEnabledByFeatureFlag=\"featureFlags.UNDERWRITING_TRACKER\"`.\n\n| Permissions\n| Route denies `REST_UW_ACCESS` and `ADVANCE_AUDITORS`; the sidebar item uses the same\n  `*ngxPermissionsExcept` clause.\n\n| Sub-permissions\n| Passed *through* to EVA rather than enforced here — EVA decides what to show.\n|===\n\nNote the flag gates *visibility*, not the route: `/underwriting/tracker` uses\n`FeatureFlagsGeneralGuard` (which always resolves true) rather than\n`FeatureFlagGuard` (which redirects to `/404` when a flag is off). With the flag off the\nmenu item disappears but the URL still renders — and the iframe will load whatever EVA\nserves.\n\n== Relationship to per-supplier underwriting\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Surface | Scope\n\n| `/underwriting/tracker`\n| Cross-supplier queue. EVA-rendered. This page.\n\n| `/suppliers/:key/underwriting/*`\n| One supplier's assessment. Angular-rendered in this repository. See\n  xref:supplier-underwriting.adoc[underwriting].\n\n| `/suppliers/:key/new-underwriting`\n| A flag-gated Angular rewrite of the per-supplier view\n  (`supplier-underwriting-beta/`), not related to the tracker.\n|===\n\nThe `underwriting/` module in this repository contains *only* the tracker. All\nper-supplier underwriting code lives under\n`suppliers/supplier/supplier-underwriting/` — a naming trap worth remembering.\n\n== Other EVA-rendered surfaces\n\nThe tracker is not unique. `EvaComponent` is also used for:\n\n* The supplier top bar (`EVA_IFRAME_SELECTOR.TOP_BAR`) and the supplier menu\n  (`EVA_SUPPLIER_MENU` flag).\n* The onboarding summary (`EVA_ONBOARDING_SUMMARY` flag).\n* Contract views (which can request a parent refresh via `CONTRACTS__REFRESH`).\n* The DEV Admin feature-flag and QA screens, which embed EVA in order to read and write\n  EVA's own flags — see xref:admin.adoc[DEV Admin].\n\n== Gotchas\n\n* The component is empty by design — do not look for tracker logic here.\n* `app-eva` takes a *relative* `url` (`'underwriting-tracker'`), resolved against\n  `evaApiUrl`.\n* `EvaComponent` clears the EVA auth cookie on `window:unload` and\n  `window:beforeunload`, so a hard refresh re-issues the token.\n* On `localhost` the token travels in the query string instead of a cookie, because the\n  parent domain differs. Behaviour therefore differs between local and deployed\n  environments.\n* The flag hides the menu entry but does not block the route.\n"},{"component":"internal-admin","module":"domains","path":"domains:admin.adoc","title":"DEV Admin","description":"Developer-only tooling — runtime feature-flag switching for both Internal Admin and EVA, plus a QA surface.","url":"https://docx.payability.com/internal-admin/domains/admin.html","navOrder":33,"source":"= DEV Admin\n:description: Developer-only tooling — runtime feature-flag switching for both Internal Admin and EVA, plus a QA surface.\n\n== What it is\n\nA developer console reachable only with the `DEV` permission. Two screens:\n\n* *Feature flags* — inspect and switch flags at runtime, for both this application and\n  EVA, and persist a flag set to global settings.\n* *QA* — an EVA-hosted QA surface.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `admin/admin.routes.ts` — `/admin/feature-flags`, `/admin/qa`, with `**` redirecting\n  to `/admin/feature-flags`\n\n| Components\n| `admin/base/base.component.ts`, `admin/feature-flags/feature-flags.component.ts`,\n  `admin/qa/qa.component.ts`\n\n| Services\n| `shared/feature-flags/feature-flags-admin.service.ts`,\n  `feature-flags-eva.service.ts`, `feature-flags.service.ts`\n\n| Model\n| `shared/feature-flags/feature-flags.model.ts` (`FeatureFlagEntity`),\n  `feature-flags.enum.ts` (`FEATURE_FLAGS_ENV`,\n  `FEATURE_FLAGS_ENV_GLOBAL_SETTINGS_KEY`)\n|===\n\n== Access\n\nThe `/admin` route is the only one in the application using an *allow* clause with\n`only`:\n\n[source,typescript]\n----\n{\n  path: 'admin',\n  canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard, NgxPermissionsGuard],\n  data: { permissions: { only: USER_PERMISSION.DEV, redirectTo: '/' } },\n  children: [ /* base → feature-flags | qa | ** → /admin/feature-flags */ ],\n}\n----\n\nThe sidebar mirrors it with `*ngxPermissionsOnly=\"userPermission.DEV\"`. `dev_support` is\na separate permission and does *not* grant access here.\n\n== The feature-flag console\n\n`AdminFeatureFlagsComponent` shows one table per environment:\n\n[source,typescript]\n----\nreadonly ENV_CONFIG = [\n  { env: FEATURE_FLAGS_ENV.IA,  title: 'Internal Admin' },\n  { env: FEATURE_FLAGS_ENV.EVA, title: 'Eva' },\n];\n----\n\nEach row is a `FeatureFlagEntity` with three independent booleans:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Column | Meaning\n\n| `build`\n| The flag was baked into the bundle at build time\n  (`environment.featureFlags`).\n\n| `globalSettings`\n| The flag is present in the global-settings record for this environment\n  (`FEATURE_FLAGS_IA` / `FEATURE_FLAGS_EVA`).\n\n| `exec`\n| The *effective* value right now for this browser session, after session overrides.\n|===\n\nSeeing all three side by side is the point: it makes \"why is this flag on here but not\nfor my colleague\" answerable at a glance. The precedence rules are in\nxref:platform:feature-flags.adoc[feature flags].\n\n=== What the console can do\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Operation | Effect\n\n| Toggle a flag\n| `FeatureFlagsService.changeFlag(flag, value)` → recomputes the table and writes the\n  new exec set to `sessionStorage`. Affects only this browser session.\n\n| Clear session overrides\n| `clearAvailableFlagsExecStatus()` → removes the `sessionStorage` keys so the resolved\n  set falls back to build + global settings.\n\n| Persist to global settings\n| `storeState(flags)` → writes the enabled flag names as a comma-separated string into\n  the `FEATURE_FLAGS_IA` (or `_EVA`) global setting, stamping `lastChange`. This affects\n  **every user in the environment**.\n\n| Refetch global settings\n| `clearGlobalSettingsFetchedFlags()` → drops the in-memory cache so the next read hits\n  Actions Tools.\n\n| Share a configuration\n| The component injects `ClipboardService` and `PlatformLocation`, and reads settings\n  from the URL (`listenSettingsFromUrl()`, `SETTINGS_KEY = 'config'`). A flag\n  configuration can be encoded in the URL and copied to a colleague.\n|===\n\n[WARNING]\n====\n\"Persist to global settings\" is not a developer-local action. It rewrites the\nenvironment-wide flag record and takes effect for other users on their next flag\ninitialisation. On a shared environment, prefer session overrides.\n====\n\n== Driving EVA's flags\n\nThe interesting part of this screen is that it manages *another application's* flags.\n\n`AdminFeatureFlagsComponent` embeds an `EvaComponent` (`@ViewChild('appEva')`) and\nregisters it with the services (`setAppEva(component)`). Then:\n\n[source]\n----\nInternal Admin                                 EVA (iframe)\n─────────────────────────────────────────────  ────────────────────────\nFeatureFlagsEvaService.initFF(available, build)\n  ├─ read FEATURE_FLAGS_EVA from global settings\n  ├─ read session overrides (FeatureExecFlagsEva)\n  ├─ recalculate exec set\n  └─ storeAndSend(flags)\n        └─ appEva.sendMessage().sendUpdatedConfig(evaFlags)\n                  ── GENERAL__CONFIG_RESPONSE ──►  applies flag set\n\n                  ◄── GENERAL__CONFIG_REQUEST ──   EVA asks for config\n                  ◄── GENERAL__CONFIG_UPDATE  ──   EVA reports its own flags\nFEATURE_FLAGS__RESET ─────────────────────────►    EVA clears overrides\n----\n\n`FeatureFlagsEvaService` keeps EVA's session overrides under *separate* keys —\n`FeatureExecFlagsEva` and `FeatureExecFlagsLastChangeEva` — so the two applications'\noverrides never collide.\n\nIt also enforces an always-on list:\n\n[source,typescript]\n----\nprivate readonly ALWAYS_ON_FLAGS = ['🎪'];\n----\n\nAny flag named in `ALWAYS_ON_FLAGS` is forced to `true` in `storeAndSend` and unioned\ninto the fetched global-settings set. The single entry is an emoji — a deliberate\nsentinel that EVA recognises and that cannot collide with a real flag name.\n\n`CommunicationService.featureFlagsFromEva$` is the inbound channel for EVA's own\navailable-flag list, so the console can render EVA's rows without hardcoding them.\n\n== The QA screen\n\n`AdminQaComponent` is minimal — a component with a single `@ViewChild('appEva')` and a\ntemplate embedding EVA. Like the underwriting tracker, the content is EVA's; this route\nexists to host it behind the `DEV` permission. See\nxref:underwriting-tracker.adoc[underwriting tracker] for the same pattern.\n\n== Non-production console output\n\nSeparately from this screen, `FeatureFlagsGeneralGuard` prints the resolved flag table\nto the browser console on the first guarded navigation when\n`environment.production` is false:\n\n[source]\n----\n[Feature Flags] Current Functionality Status:\n\nSUPPLIER_ACCOUNT_HEALTH: Enabled\nTASKS_MID_VER_SIMPLE_QUERY: Disabled\n…\n----\n\nIt is printed once per page load (guarded by a `displayed` flag). That is often faster\nthan opening `/admin/feature-flags`.\n\n== Other developer affordances\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Tool | Detail\n\n| Redux DevTools\n| `StoreDevtoolsModule.instrument()` is enabled unconditionally, including in production\n  builds.\n\n| Store debugger\n| `{source-root}/store-debugger.ts` logs every state/action pair, but requires editing\n  `EnableStoreDebugging = false` in source. Use DevTools instead.\n\n| Request ring buffer\n| `FetchedRequestsInterceptor` keeps the last 50 requests (serialised) in the `errors`\n  slice — visible in DevTools.\n\n| Error-handling playground\n| `shared/error-handling/tetris/` with `game.service.ts` — a Tetris game shown on the\n  error page. See xref:platform:error-handling.adoc[error handling].\n\n| Version banner\n| `shared/layout/app-stage-info/` shows the environment and build; the sidebar footer\n  shows front-end and back-end versions via `VersionService`. See\n  xref:platform:observability.adoc#versions[versions].\n\n| VPN tester\n| `shared/vpn-tester.service.ts`, started when `CHECK_VPN` is on.\n|===\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| `DEV` only, not `dev_support`\n| Two similar-looking permissions; only `DEV` opens `/admin`.\n\n| Persisting affects everyone\n| `storeState` writes the environment-wide global setting.\n\n| Session vs global storage\n| Session overrides live in `sessionStorage` (per tab); global settings live in Actions\n  Tools (per environment).\n\n| The emoji flag is real\n| `ALWAYS_ON_FLAGS = ['🎪']` is intentional, not a stray character.\n\n| `initFF` has a disabled early-return\n| `if (this.initialized) { // return false; }` — the guard is commented out, so EVA flag\n  initialisation always re-runs.\n\n| The `**` child redirect\n| `/admin/anything` lands on `/admin/feature-flags`, unlike the `''` redirect used\n  elsewhere.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:authentication.adoc","title":"Authentication and session","description":"How a user signs in, how the session token is carried, when it expires, and what happens on logout.","url":"https://docx.payability.com/internal-admin/platform/authentication.html","navOrder":34,"source":"= Authentication and session\n:description: How a user signs in, how the session token is carried, when it expires, and what happens on logout.\n\n== Two ways in\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Method | Flow\n\n| *Google SSO* (primary)\n| Google Identity Services renders the sign-in button, returns a credential, and the\n  app exchanges it with the internal API for a Payability JWT.\n\n| *Username and password* (fallback)\n| An HTTP Basic header against `internalApiUrl + auth/login`, which returns the same\n  kind of JWT.\n|===\n\nBoth converge on the same post-login work: load the user record, load their\npermissions, persist the session, and redirect.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Routes\n| `auth/auth-routing.module.ts` — `/login`, `/logout`, declared with\n  `RouterModule.forChild` and therefore rendered *outside* `MainLayoutComponent`\n  (no sidebar)\n\n| Components\n| `auth/login/`, `auth/logout/`, `auth/google-auth-login/` (with a vendored\n  `ng-gapi/`)\n\n| Store\n| `auth/store/auth.actions.ts`, `auth.reducers.ts`, `auth.effects.ts`,\n  `auth-mock.reducers.ts`\n\n| Interceptor\n| `auth/auth-interceptor.ts`\n\n| Guards\n| `auth/guards/auth.guard.ts`, `shared/utils/route-access-resolver.ts`\n\n| User model\n| `shared/users/user.model.ts`, `user-perm.model.ts`\n|===\n\n== Google sign-in\n\n`GoogleAuthLoginComponent` uses *Google Identity Services* (the newer\n`google.accounts.id` API), not the legacy `gapi.auth2` flow, despite the `@types/gapi`\ndependency:\n\n[source,typescript]\n----\nwindow.onGoogleLibraryLoad = () => {\n  google.accounts.id.initialize({\n    client_id: this.clientId,             // env.googleAuthClientId\n    callback: this.googleResponse.bind(this),\n    auto_select: true,\n    cancel_on_tap_outside: true,\n    itp_support: true,\n    context: 'signin',\n    ux_mod: 'popup',\n  });\n  google.accounts.id.renderButton(document.getElementById('loginBtn'), { … });\n  google.accounts.id.prompt();           // One Tap\n};\n----\n\n`auto_select: true` plus `prompt()` means a returning user can be signed in without\nclicking anything.\n\nThe component emits `GoogleSignInSuccess` / `GoogleSignInFailure`;\n`LoginComponent` dispatches `TryLoginGoogle({ googleAuthUser })`.\n\n`AuthEffects.authLoginGoogle` then:\n\n. Reads `googleAuthResult.credential`. If absent it dispatches `LOGIN_FAILED` with\n  the message `'Something change in google auth response'` — a deliberate guard against\n  Google changing its response shape.\n. Calls `LookupRepository.getAuthGoogleLogin(credential)`.\n. Builds `{ jwtToken: result.token, username: result.decoded.email }` and hands off to\n  `handleFullLogin`.\n\n[NOTE]\n====\nThe Google client id is the *same string* in the dev and production configuration\ntemplates. Only specific `localhost` ports are registered with Google — 4200, 4300,\n4400, 4500, 4600, 5000, 8082. Serving on any other port breaks Google sign-in. See\nxref:ops:local-development.adoc[local development].\n====\n\n== Username and password\n\n`AuthEffects.authLogin` constructs the Basic header inline:\n\n[source,typescript]\n----\nconst authEndpoint = `${this.appConfig.env.internalApiUrl}auth/login`;\nconst headers = { Authorization: 'Basic ' + btoa(`${username}:${password}`) };\nreturn this.httpClient.get(authEndpoint, { headers: new HttpHeaders(headers) }) …\n----\n\nThis is the one request that carries an `Authorization: Basic` header;\n`AuthInterceptor` leaves it alone because there is no token in state yet.\n\n`LoginComponent` maps the failure status to a message: `401` →\n\"Invalid login or password\", `403` → \"Access denied\", anything else → \"Cannot connect\nwith server\", then dispatches `ClearAuthState`.\n\n== Completing the login\n\n`AuthEffects.handleFullLogin(currentUser)` is shared by both paths:\n\n[source,typescript]\n----\nthis.usersService.getUser(currentUser.username, currentUser.jwtToken).pipe(\n  mergeMap((user) => {\n    const perms = user.perms.map((p) => p.permCode);\n    this.permissionsService.loadPermissions(perms);      // ngx-permissions\n    return [{ type: LOGIN, payload: { user: { ...user, ...currentUser } } }];\n  }),\n)\n----\n\nSo the stored `user` object is the *API user record merged with the token* —\n`{ …user, jwtToken, username }`. `perms` is an array of `UserPermModel`, and the\npermission *codes* are what `ngx-permissions` receives.\n\n`AppComponent` also calls `loadPermissions` on every `auth` state change, which is what\nrestores permissions after a page reload from persisted state. See\nxref:authorization.adoc[authorisation].\n\n== The auth slice\n\n[source,typescript]\n----\nexport interface AuthState {\n  isAuthenticated: boolean;\n  user?: any;                     // deliberately untyped\n  authError?: HttpErrorResponse;\n}\n----\n\n`user` is `any` because it is the merge of the API record and the token. `UserModel` is\nmaterialised on demand by the `selectLoggedUserModel` selector:\n\n[source,typescript]\n----\nexport const selectLoggedUserModel = createSelector(selectAuth, (state) => {\n  if (hasCorrectToken(state) && hasCorrectData(state)) {\n    return Deserialize(state.user, UserModel);\n  }\n});\n----\n\nTwo validity predicates are exported and reused across the app:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Predicate | Checks\n\n| `hasCorrectToken(authState)`\n| `user` is defined, `user.jwtToken` is defined, is a string, and is truthy.\n\n| `hasCorrectData(authState)`\n| `user` is defined and `user.loginId` is truthy.\n|===\n\nSelectors: `selectAuthToken`, `selectLoggedUserModel`, `selectLoggedUserLoginKey`,\n`isLoggedIn`.\n\n=== Persistence\n\nThe *reducer* is where session persistence happens, at module-evaluation time:\n\n[source,typescript]\n----\nexport function authReducers(state = getPersistedState() || initialState, action) {\n  switch (action.type) {\n    case LOGIN:\n      setPersistedState(action.payload.user);      // localStorage['currentUser']\n      return { ...state, user: action.payload.user, isAuthenticated: true };\n    case LOGIN_FAILED:\n      return { ...state, authError: action.payload, isAuthenticated: false };\n    case CLEAR_AUTH_STATE:\n    case LOGOUT:\n      return { ...initialState };\n    default: return state;\n  }\n}\n----\n\n`getPersistedState()` parses `localStorage['currentUser']` and, if present, returns\n`{ user, isAuthenticated: true }`. So *the presence of the stored object is what makes\nthe app consider you logged in* — `isAuthenticated` is derived, not stored.\n\n`localStorage['currentUser']` is also read directly, outside the store, by\n`EvaComponent` when handing the token to EVA.\n\n[WARNING]\n====\nThe JWT sits in `localStorage` in clear text, and the request ring buffer in the\n`errors` slice records recent requests. Both are visible to anything running in the\npage. This is accepted for an internal tool on a controlled network; do not extend the\npattern to more sensitive data.\n====\n\n== Carrying the token\n\n`AuthInterceptor` (`AUTH_HEADER_NAME = 'X-Session-Token'`) selects the `auth` slice with\n`take(1)` on every request and, when `hasCorrectToken` holds, clones the request with the\nheader. Requests made before login pass through unmodified.\n\nEvery Payability backend accepts this header, so the same token goes to a dozen\ndifferent hosts. `CrmService` additionally sets the raw token as `Authorization`, and\n`SseService` passes it explicitly because `EventSource` cannot set headers.\n\nSee xref:architecture:http-and-services.adoc#interceptors[the interceptor chain].\n\n== Expiry and 401\n\nThere is *no token refresh*. Expiry is detected in two places:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Detector | Behaviour\n\n| `RouteAccessResolver` (on every guarded navigation)\n| Decodes the JWT with `jwt-decode` and checks the `token` claim against\n  `Date.now() / 1000`. Also rejects a user with no `loginId`. On rejection it stores the\n  current URL and logs out.\n\n| `AuthInterceptor` (on every response)\n| A `401` *with* a token present dispatches\n  `Logout({ message: 'Auth token expired, please try to login again.' })` and rethrows.\n|===\n\n[NOTE]\n====\nThe expiry claim is named `token`, not the standard `exp`:\n\n[source,typescript]\n----\nconst encodedToken: any = JWT(jwtToken);\nif (!isUndefined(encodedToken.token) && encodedToken.token < Date.now() / 1000) return true;\n----\n\nIf the claim is absent the token is treated as *not expired*. Only a 401 will then end\nthe session.\n====\n\nBecause expiry is only checked on navigation, a user sitting on one screen keeps a dead\ntoken until their next request fails.\n\n== Logout\n\n`/logout` renders `LogoutComponent`, whose only job is to dispatch `Logout()`.\n`AuthEffects.authLogout` (a `dispatch: false` effect):\n\n. Calls `clearStorages()` — which is `localStorage.clear()`, i.e. it removes\n  *everything*, not just the session: the persisted supplier query\n  (`suppliers_last_query`) and the `lscache` reference-data cache go too.\n. Navigates to `/login`, passing `payload.message` as a `message` query parameter when\n  present.\n. Wrapped in `try/catch` that repeats both steps on failure.\n\n`LOGIN_FAILED` triggers the same `clearStorages()` through the\n`loginFailedCleanup` effect.\n\n`LoginComponent.ngOnInit` additionally clears feature-flag session state\n(`clearAvailableFlagsExecStatus()` and `FeatureFlagsEvaService.reset()`), so flag\noverrides do not survive a logout. `EvaComponent` clears the EVA auth cookie on\n`window:unload`.\n\n`?message=` on `/login` is toasted as an info notification for 10 seconds — the channel\nby which \"your session expired\" reaches the user.\n\n== Redirect after login\n\n[source]\n----\nRouteAccessResolver.handleLogout()\n  backUrl = location.href − origin, with /#/ stripped   →  sessionStorage['loginRedirectUrl']\n  (unless backUrl is already '/login')\n                                ⋮\nAuthEffects.login (on LOGIN)\n  redirect = sessionStorage['loginRedirectUrl'] || '/'\n  ngZone.run(() => router.navigate([redirect]))\n----\n\n`ngZone.run` is required because the Google callback resolves outside Angular's zone.\n\n[CAUTION]\n====\n`loginRedirectUrl` is never cleared after use. A later logout overwrites it, but if a\nuser logs out from `/login` itself the previous value survives, so the *next* login may\nnavigate somewhere unexpected.\n====\n\n== Guards\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Guard | Used on\n\n| `RouteAccessResolver`\n| Every feature area. Full validity check plus redirect capture. Despite the name it is\n  a `CanActivate` guard.\n\n| `AuthGuard`\n| `/welcome` only. A simpler `isLoggedIn` check that redirects to `/login`.\n|===\n\nBoth are provided in `AppModule`. See xref:architecture:routing.adoc[routing] for the\nguard order.\n\n== The user model\n\n`UserModel` (`shared/users/user.model.ts`) carries `loginKey`, `loginId`, `firstname`,\n`lastname`, `email`, `username`, `phoneNumber`, `countryCode`, `authyId`,\n`authyRegVerified`, and `perms`. The `authy*` fields indicate a two-factor enrolment for\n*internal users* (distinct from supplier 2FA).\n\nDerived members:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Member | Behaviour\n\n| `id`, `key`\n| Aliases for `loginId` and `loginKey`.\n\n| `name`\n| `\"first last\"`, falling back to `loginId` when both names are empty.\n\n| `fullname`\n| `\"first last\"` with no fallback.\n\n| `isOwner(reqModel)`\n| `reqModel.ownerUuid === this.loginKey`.\n\n| `isSuperUser(reqModel, actionName)`\n| True if the user holds `CRM_ADMIN` — *ignores* `reqModel` entirely and only checks\n  that `actionName` is truthy.\n\n| `hasAccessTo(item, action)`\n| `isOwner(item) \\|\\| isSuperUser(item, action)`.\n|===\n\n[CAUTION]\n====\n`isSuperUser` is misleadingly named and misleadingly parameterised: it is a\n`CRM_ADMIN` check, not a general super-user check, and neither argument affects the\nresult beyond a truthiness test. `hasAccessTo` is used for CRM item ownership; do not\nreuse it as a general authorisation helper.\n====\n\nAlso note `UserModel` has getters, and — like `SupplierDetailsModel` — they are lost\nwhen the object is spread. See\nxref:architecture:data-modeling.adoc[data modelling].\n\n== Related\n\n* xref:authorization.adoc[Authorisation and permissions] — what the loaded permissions\n  gate.\n* xref:caching-and-persistence.adoc[Caching and persistence] — every storage key,\n  including `currentUser` and `loginRedirectUrl`.\n* xref:iframe-messaging.adoc[Iframe messaging and EVA] — how the token reaches EVA.\n* xref:observability.adoc[Observability] — the Segment `identify` call made with the\n  logged-in user.\n"},{"component":"internal-admin","module":"platform","path":"platform:authorization.adoc","title":"Authorisation and permissions","description":"How access is gated — ngx-permissions, the two deny-filter permissions, supplier-scoped and sub-permissions, and where enforcement actually happens.","url":"https://docx.payability.com/internal-admin/platform/authorization.html","navOrder":35,"source":"= Authorisation and permissions\n:description: How access is gated — ngx-permissions, the two deny-filter permissions, supplier-scoped and sub-permissions, and where enforcement actually happens.\n\n[IMPORTANT]\n====\nClient-side authorisation in this application is *advisory*. It shapes what a user sees\nand can attempt. Every protected operation is enforced by the backend. A missing guard\nis a usability bug; a missing backend check is a security bug. See\nxref:architecture:decisions.adoc[decision D6].\n====\n\n== The permission vocabulary\n\n`{source-root}/shared/users/user-permissions.enum.ts` defines 40 codes. They divide\nroughly into:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Kind | Codes\n\n| Login / session\n| `AUTH_TOKEN`, `SUP_LOGIN`, `INT_LOGIN`\n\n| Broad role\n| `INT_SUPER_USER`, `VIEW_ONLY`, `FINANCE`, `MARKETING`, `CUST_SERVICE`, `Sales`,\n  `UNDERWRITING`, `PROD_MGR`, `US` (`'us'`), `DEV`, `dev_support`, `DELETED`\n\n| Administration\n| `INT_UPDATE`, `INT_PERMS_ADM`, `INT_GLOBAL_SETTINGS`, `INT_EMAIL_SETTINGS`,\n  `INT_RULES_UPDATE`, `INT_REP_ADMIN`, `CRM_ADMIN`\n\n| Money operations\n| `INT_MP_PMTS`, `INT_RCV`, `INT_XACT_CNCL`, `INT_FEE_UPD`, `INT_PMT_ADMIN`,\n  `INT_PMT_REVIEW`, `MANAGE_PMT`, `MANAGE_BAD_DEBT`\n\n| Supplier data\n| `INT_SUP_CRED`, `MANAGE_LINKING_PAYMENT_GATEWAYS`\n\n| Marketplace sub-permissions\n| `MP_LOANS_MODIFY`, `MP_LOANS_DELETE`, `MP_STATEMENTS_MODIFY`,\n  `MP_STATEMENTS_DELETE`\n\n| *Deny filters*\n| `REST_UW_ACCESS`, `ADVANCE_AUDITORS`\n|===\n\nNote the inconsistent casing: most are `SCREAMING_SNAKE`, but `Sales`, `us` and\n`dev_support` are not. The enum *values* are what the backend sends; match them exactly.\n\nFull catalogue with enforcement points:\nxref:reference:permission-catalog.adoc[the permission catalogue].\n\n== The two deny filters\n\n`REST_UW_ACCESS` (restricted underwriting access) and `ADVANCE_AUDITORS` are not grants —\nthey are *restrictions*. Holding one removes access rather than adding it, and they are\napplied with `except:` clauses across the whole application.\n\n[source,typescript]\n----\ndata: { permissions: { except: [USER_PERMISSION.REST_UW_ACCESS,\n                                USER_PERMISSION.ADVANCE_AUDITORS] } }\n----\n\nTheir reach goes well beyond routing:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Mechanism | Effect\n\n| Route clauses\n| Nearly every feature area and supplier tab.\n\n| Sidebar\n| Almost every menu item is wrapped in\n  `*ngxPermissionsExcept=\"[REST_UW_ACCESS, ADVANCE_AUDITORS]\"`.\n\n| Body class\n| `AppComponent` adds `restricted-access` to `document.body` for `REST_UW_ACCESS`,\n  letting SCSS hide UI wholesale.\n\n| Data loading\n| `SupplierEffects.getSupplierDetails` replaces the funding, balance and contract calls\n  with `of(null)` for `ADVANCE_AUDITORS` — the data never reaches the browser.\n\n| Audit logging\n| `MonitorService.send()` only logs when the user holds one of these two permissions —\n  access by restricted users is traced as a KYC guardrail event. See\n  xref:observability.adoc[observability].\n|===\n\nThree route-level variants are defined in\n`suppliers/suppliers-routing.routes.ts` and spread into supplier tabs:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Constant | Denies\n\n| `RESTRICTED_UW_ACCESS_CONFIG`\n| Both — the common case.\n\n| `RESTRICTED_AUDITORS_ACCESS_CONFIG`\n| `REST_UW_ACCESS` only. Auditors *are* allowed (used on the `general` tab).\n\n| `RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG`\n| `ADVANCE_AUDITORS` only. Restricted-UW users *are* allowed (used on the\n  `underwriting` parent).\n|===\n\n[CAUTION]\n====\nThe `underwriting` parent allows `REST_UW_ACCESS` but most of its children deny it, so a\nrestricted user can reach the tab and then be bounced from `daily` and `ucc`. That is by\ndesign, not a bug — but it makes the effective permission surface hard to reason about\nfrom either the route table or the sidebar alone.\n====\n\nOnly two routes use an *allow* clause: `/admin` (`only: DEV`) and\n`suppliers/:key/underwriting/background-reports` (`only: FINANCE`). Because `only` is an\nallow-list, `FINANCE` blocks even super users who lack that specific code.\n\n== The four gating mechanisms\n\n=== 1. `NgxPermissionsGuard` on routes\n\nReads `data.permissions` with `only` / `except` and an optional `redirectTo`. Runs third,\nafter `RouteAccessResolver` and `FeatureFlagsGeneralGuard`. See\nxref:architecture:routing.adoc[routing].\n\nPermissions are loaded into `ngx-permissions` in two places:\n`AuthEffects.handleFullLogin` at login, and `AppComponent`'s `auth` subscription on\nevery state change — the latter is what restores them after a reload.\n\n=== 2. Template directives\n\n`*ngxPermissionsOnly` and `*ngxPermissionsExcept` appear in ~44 template locations,\nconcentrated in `sidenav-menu.component.html` and the supplier workspace.\n\n[NOTE]\n====\nThe sidebar's permission clauses are maintained *separately* from the route's. A\nmismatch shows a user a link that immediately bounces them. When you change a route's\npermissions, change the menu entry too.\n====\n\n=== 3. Imperative checks in components\n\nTwo different services, with different semantics — this is the most common source of\nconfusion.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Call | Semantics\n\n| `NgxPermissionsService.getPermission(code)`\n| *Synchronous.* Returns the permission object or `undefined`. Used as\n  `!!getPermission(...)` or `!= null`. Reads the in-memory permission set.\n\n| `NgxPermissionsService.hasPermission(code)`\n| Returns a **`Promise<boolean>`**. Used with `fromPromise(...)` or `.then(...)`.\n\n| `UsersService.checkPermission(code, user?)`\n| Returns an **`Observable<boolean>`**. Hits the users service; may consult the API.\n\n| `UsersService.checkPermissions(codes, user)`\n| Observable, *all* codes.\n\n| `UsersService.checkGeneralPermission(s)`\n| Observable variants that check the `generalPerm` flag rather than a\n  supplier-scoped grant.\n|===\n\nAll five are in live use. `getPermission` for cheap synchronous hiding,\n`checkPermission` where the answer may depend on the user record.\n\n=== 4. `SubPermissionsGuard`\n\nFor sub-permissions, which `ngx-permissions` does not model:\n\n[source,typescript]\n----\n// route\ncanActivate: [SubPermissionsGuard],\ndata: { requireOneOfSubPermissions: [USER_PERMISSION.MP_LOANS_MODIFY] }\n----\n\nThe guard maps each code through `UsersService.checkPermission`, `combineLatest`es them\nand passes if *any* is true. On failure it toasts `'Access denied.'` and navigates to\n`/`. If `requireOneOfSubPermissions` is missing or empty it *denies* — a safe default.\n\nUsed only on the Amazon-lending add-loan and edit-loan routes. See\nxref:domains:marketplaces.adoc[marketplaces].\n\n== Permission models\n\n[source,typescript]\n----\n// A permission granted to a user\nclass UserPermModel {\n  permCode: USER_PERMISSION | string;\n  permDesc: string;\n  supplierKey: string;        // ← non-null means the grant is scoped to one supplier\n  loginPermKey: string;       // the grant's own id, needed to revoke it\n}\n\n// A permission/group definition\nclass PermissionBaseModel { generalPerm: boolean; permCode; permDesc; }\n\n@inheritSerialization(PermissionBaseModel)\nclass PermissionModel extends PermissionBaseModel {\n  readonly type = 'group';\n  assignable: boolean;        // may be assigned through the UI\n  specSupPerm: boolean;       // may be scoped to a specific supplier\n  get id()   { return this.permCode; }\n  get key()  { return this.permCode; }\n  get name() { return this.permDesc; }\n}\n----\n\nThree flags define the shape of the permission system:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Flag | Meaning\n\n| `generalPerm`\n| The permission applies globally rather than per supplier. `checkGeneralPermission`\n  tests this.\n\n| `specSupPerm`\n| The permission *can* be scoped to a specific supplier — granted with a\n  `supplierKey`.\n\n| `assignable`\n| The permission may be granted through the Manage UI. Non-assignable permissions exist\n  but are managed elsewhere.\n|===\n\n[IMPORTANT]\n====\n`supplierKey` on `UserPermModel` means Internal Admin supports *supplier-scoped\npermissions* — a user can hold a permission for one supplier only. `ngx-permissions`\nknows nothing about this: `loadPermissions(perms.map(p => p.permCode))` discards the\nscope. So route and template gating is scope-blind; only `UsersService.checkPermission`\n(and the backend) can honour a supplier-scoped grant.\n\nIf you need scope-aware gating, use `UsersService`, not the directives.\n====\n\n`PermissionModel.type = 'group'` and `UserModel.type = 'user'` let the two be mixed in\none list — which `UsersAbstractService.getCombinedList()` does, sorting by `name`. That\nis what powers assignee pickers that offer both users and groups.\n\n== Sub-permissions\n\n`GET perms/:permCode/sub-perms` returns the sub-permissions of a permission.\n`UsersService` caches the result in an instance field after the first call, for any\n`permCode` — so the cache is keyed by *nothing* and a second call for a different\npermission returns the first one's sub-permissions.\n\n[WARNING]\n====\n[source,typescript]\n----\npublic getPermissionSubPerms(permCode: string): Observable<any[]> {\n  if (!isNullOrUndefined(this.subPermsArr)) { return of(this.subPermsArr); }   // ← no key\n  return this.httpClient.get(`${this.permsApiUrl}/${permCode}/sub-perms`)…\n}\n----\n\nThe cache ignores `permCode`. In practice only one `permCode` is queried per session\n(the logged-in user's, by `EvaComponent`), which is why it has not surfaced as a bug.\nDo not add a second caller without fixing the cache.\n====\n\nSub-permissions are also what `EvaComponent` forwards to EVA, so EVA's authorisation\nderives from this call. See xref:iframe-messaging.adoc[iframe messaging].\n\n== Administering permissions\n\n`/manage/users` and `/manage/perm-groups`, gated by `INT_PERMS_ADM`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Operation | Endpoint\n\n| List permissions/groups\n| `GET perms`\n\n| A user's grants\n| `GET logins/:loginKey/perms`\n\n| Grant\n| `addPermissionToLogin(loginKey, { permCode, supplierKey })` — note the optional\n  supplier scope\n\n| Revoke\n| `removePermissionFromLogin(loginKey, loginPermKey)` — by grant id, not by code\n|===\n\nSee xref:domains:manage.adoc[manage].\n\n== Beyond permissions\n\nTwo other gates that are easy to confuse with authorisation:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Gate | Difference\n\n| Feature flags\n| Control whether a *capability exists* in this environment, not who may use it.\n  Composable with permissions — a route can be both flagged and permission-gated. See\n  xref:feature-flags.adoc[feature flags].\n\n| VPN check\n| `VpnTesterService`, behind the `CHECK_VPN` flag, warns when the operator is off the\n  corporate network. Advisory; the real network restriction is upstream.\n|===\n\n== Practical guidance\n\n* Adding a restricted route: add the permission clause *and* the matching sidebar\n  clause.\n* Hiding a control: prefer `*ngxPermissionsExcept` / `*ngxPermissionsOnly` in the\n  template over an imperative check.\n* Needing an async answer or supplier scope: use `UsersService.checkPermission`.\n* Never rely on a guard as a security control.\n* When in doubt about which of `getPermission` / `hasPermission` /\n  `checkPermission` to use, match the file you are editing — all three appear, and mixing\n  paradigms in one component is worse than picking the local convention.\n"},{"component":"internal-admin","module":"platform","path":"platform:feature-flags.adoc","title":"Feature flags","description":"The three flag layers, their precedence, and the four ways to apply a flag.","url":"https://docx.payability.com/internal-admin/platform/feature-flags.html","navOrder":36,"source":"= Feature flags\n:description: The three flag layers, their precedence, and the four ways to apply a flag.\n\n[IMPORTANT]\n====\nThe repository's own convention, stated in capitals in the README: feature flags\n\"SHOULD BE USED ON ALL MODULES BY DEFAULT\". New modules and routes are expected to be\nflag-gated. See xref:architecture:decisions.adoc[decision D7].\n====\n\n== The vocabulary\n\n`{source-root}/shared/feature-flags/feature-flags.const.ts` holds the `FEATURE_FLAGS`\nenum — the single list of valid flag names. Always add to it; never use a bare string.\n\nCurrent flags and what they gate:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Flag | Gates\n\n| `SUPPLIER_ACCOUNT_HEALTH`\n| The supplier health tab and health cards.\n\n| `TASKS_MID_VER_SIMPLE_QUERY`\n| A simpler task-counter query in `TaskCountBadgeComponent`.\n\n| `UNDERWRITING_TRACKER`\n| The `/underwriting` tracker menu item.\n\n| `BANK_CATEGORIES`\n| The bank-transaction-categories underwriting tab.\n\n| `EVA_ONBOARDING_SUMMARY`\n| EVA-rendered onboarding summary.\n\n| `ACCESS_ONBOARDING_PREDICTIONS`\n| The onboarding ML-prediction underwriting tab.\n\n| `ACCESS_DAILY_PREDICTIONS`\n| The daily ML-prediction underwriting tab.\n\n| `TWO_FA`\n| Supplier two-factor management.\n\n| `UNDERWRITING_CHECK_TOOLS_BEFORE_APPROVING`\n| Requires UW tool results before approval.\n\n| `PYB_DOCUMENTS`\n| The PYB documents backend.\n\n| `EVA_SUPPLIER_MENU`\n| EVA-rendered supplier menu.\n\n| `CHECK_VPN`\n| Starts `VpnTesterService`.\n\n| `DELETE_SUPPLIER`\n| Supplier account deletion.\n\n| `CARD_MANAGEMENT_AIRWALLEX`\n| The Airwallex card programme.\n|===\n\nAlso relevant, in `feature-flags.enum.ts`:\n\n[source,typescript]\n----\nexport enum FEATURE_FLAGS_ENV { IA = 'IA', EVA = 'EVA' }\n\nexport enum FEATURE_FLAGS_ENV_GLOBAL_SETTINGS_KEY {\n  IA  = 'FEATURE_FLAGS_IA',\n  EVA = 'FEATURE_FLAGS_EVA',\n}\n----\n\nFlags are managed for *two* applications: this one and EVA.\n\n== The three layers\n\n[source]\n----\n┌───────────────────────────────────────────────────────────────────┐\n│ 1. BUILD          environment.featureFlags                        │\n│                   a comma-separated string, no spaces             │\n│                   baked in by deployment/set-env.ts from          │\n│                   $FEATURE_FLAGS at build time                    │\n│                   → FeatureFlagsService.featureFlags              │\n├───────────────────────────────────────────────────────────────────┤\n│ 2. GLOBAL SETTINGS  Actions Tools setting FEATURE_FLAGS_IA / _EVA  │\n│                   config_value = { flags: 'A,B,C', lastChange: n } │\n│                   environment-wide, editable in DEV Admin          │\n├───────────────────────────────────────────────────────────────────┤\n│ 3. SESSION        sessionStorage['FeatureExecFlags']               │\n│                   + ['FeatureExecFlagsLastChange']                │\n│                   (EVA: 'FeatureExecFlagsEva' / …'LastChangeEva')  │\n│                   per-tab overrides, set in DEV Admin              │\n└───────────────────────────────────────────────────────────────────┘\n                              ↓\n                     resolved \"exec\" set\n----\n\nAn empty `featureFlags` string means *all build flags are disabled* — flags are opt-in,\nnever opt-out.\n\n=== Precedence\n\n`FeatureFlagsService.getRecalculatedFlagsStatus()` decides. For the `IA` environment:\n\n. `availableFlags` = every member of the `FEATURE_FLAGS` enum.\n. `buildProvidedFlags` = parsed `environment.featureFlags`.\n. `globalSettingsProvidedFlags` = parsed from the `FEATURE_FLAGS_IA` setting.\n. `execFlags` starts as a copy of the *global settings* set — note that build flags do\n  not seed it.\n. Session overrides are applied **only if they are newer than the global-settings\n  record**:\n+\n[source,typescript]\n----\nconst lastChangedInStorage = this.getLastChangedByKey();           // setting's lastChange\nconst lastChangedInSession = this.getLastChangeExecFlagsFromSession();\nif (lastChangedInSession > lastChangedInStorage || lastChangedInStorage === 0) {\n  availableFlagExecStatus = this.getAvailableFlagsExecStatus();     // use session\n} else {\n  availableFlagExecStatus = [];                                    // discard session\n}\n----\n. `applyExecFlags` then adds every `true` override and removes every `false` one.\n. The result is one row per flag: `{ key, build, exec, globalSettings }`.\n\n[IMPORTANT]\n====\nTwo non-obvious consequences.\n\n*Global settings beat build flags.* The exec set is seeded from global settings, so a\nflag baked into the bundle but absent from `FEATURE_FLAGS_IA` resolves to **off** — with\n`build: true` and `exec: false` in the DEV Admin table.\n\n*Editing the global setting invalidates session overrides.* Writing the setting stamps a\nnew `lastChange`, which makes every existing session override older and therefore\ndiscarded. Someone else's DEV Admin \"save to global settings\" silently resets your local\ntoggles.\n====\n\n=== `isEnabled` at runtime\n\n[source,typescript]\n----\nisEnabled(feature: FEATURE_FLAGS): boolean {\n  const flags = [...this.featureFlags];                     // build flags\n  this.applyExecFlags(this.getAvailableFlagsExecStatus(), flags);   // + session overrides\n  if (flags.length === 0 || typeof feature !== 'string') return false;\n  return flags.findIndex((f) => f === feature) >= 0;\n}\n----\n\n[CAUTION]\n====\n`isEnabled` reads *build flags plus session overrides* — it does **not** consult global\nsettings directly. It works because `initFF()` writes the fully-resolved set (including\nglobal settings) into `sessionStorage` before any `isEnabled` call, and\n`FeatureFlagsGeneralGuard` runs `initFF()` on every guarded navigation.\n\nThe practical implication: `isEnabled` is only correct *after* a guarded navigation. Do\nnot call it in a constructor that can run before the first guard — `AppComponent` does\nexactly that for `CHECK_VPN`, which is why the VPN tester can miss its flag on the very\nfirst load.\n====\n\n== Initialisation\n\n`FeatureFlagsGeneralGuard` (`{source-root}/guards/feature-flags-general.guard.ts`) is on\nevery feature route, second in the guard order:\n\n[source,typescript]\n----\nreturn this.store.select('auth').pipe(\n  filter((data) => data && data.user),          // wait for a logged-in user\n  first(),\n  switchMap(() => from(this.featureFlagsService.initFF())),\n  tap(() => { if (!environment.production) this.displayFeatureFlagsStatus(); }),\n  map(() => true),\n  catchError(() => of(true)),                   // never block on flag failure\n);\n----\n\nThree things to note:\n\n* It *waits for authentication* — flags cannot resolve before login, because the\n  global-settings read needs a token.\n* It always resolves `true`. A flag-service outage degrades to build-flag behaviour\n  rather than locking users out.\n* In non-production it logs the resolved table once per page load:\n+\n[source]\n----\n[Feature Flags] Current Functionality Status:\n\nSUPPLIER_ACCOUNT_HEALTH: Enabled\nTASKS_MID_VER_SIMPLE_QUERY: Disabled\n…\n----\n\n`initFF()` itself:\n\n[source,typescript]\n----\nasync initFF(): Promise<void> {\n  this.featureFlags = this.parseRawFlags(environment.featureFlags);\n  const flags = await this.getRecalculatedFlagsStatus();\n  this.storeAvailableFlagsExecStatus(flags.map(({ key, exec }) => ({ [key]: exec })));\n}\n----\n\n`LoginComponent` clears session flag state on entry\n(`clearAvailableFlagsExecStatus()` and `FeatureFlagsEvaService.reset()`), so overrides do\nnot survive a logout.\n\n== The four ways to apply a flag\n\n=== 1. Service check\n\n[source,typescript]\n----\nif (this.featureFlagsService.isEnabled(FEATURE_FLAGS.DELETE_SUPPLIER)) { … }\n----\n\nFor logic. Note the `isEnabled` caveat above.\n\n=== 2. Structural directive\n\n[source,html]\n----\n<div *appEnabledByFeatureFlag=\"featureFlags.EVENT_RULES\">…</div>\n----\n\n`EnabledByFeatureFlagDirective` wraps `NgIf` by instantiating it manually:\n\n[source,typescript]\n----\nthis.ngIfDirective = new NgIf(this.viewContainer, this.templateRef);\nthis.ngIfDirective.ngIf = this.featureFlagsService.isEnabled(featureName);\n----\n\n[CAUTION]\n====\nThe flag is evaluated *once*, in the input setter. Toggling a flag in DEV Admin does not\nre-render already-created views — a reload is required.\n====\n\n=== 3. Route guard\n\n[source,typescript]\n----\n{\n  path: 'documentation',\n  component: DocumentationComponent,\n  canLoad: [FeatureFlagGuard],\n  canActivate: [FeatureFlagGuard],\n  data: { enabledByFeatureFlag: FEATURE_FLAGS.PYB_DOCUMENTS },\n}\n----\n\n`FeatureFlagGuard` returns `true` when `data` or `data.enabledByFeatureFlag` is absent,\nand navigates to `/404` when the flag is off. Note `canLoad` has no effect here — nothing\nis lazily loaded.\n\n[NOTE]\n====\n`FeatureFlagGuard` (blocks the route) and `FeatureFlagsGeneralGuard` (initialises flags,\nalways passes) are different guards with confusingly similar names. Most routes have the\nlatter; only two have the former. A flag applied only through the sidebar directive hides\nthe link but leaves the URL reachable — as is the case for\n`UNDERWRITING_TRACKER`.\n====\n\n=== 4. Template directive in the sidebar\n\n[source,html]\n----\n<ng-container *appEnabledByFeatureFlag=\"featureFlags.UNDERWRITING_TRACKER\">\n  <li *ngxPermissionsExcept=\"[…]\"><a [routerLink]=\"['/underwriting']\">…</a></li>\n</ng-container>\n----\n\nFlags and permissions compose; both must pass.\n\n== EVA flags\n\n`FeatureFlagsEvaService` manages *EVA's* flags from inside Internal Admin:\n\n* Separate session keys: `FeatureExecFlagsEva`,\n  `FeatureExecFlagsLastChangeEva`.\n* Separate global-settings key: `FEATURE_FLAGS_EVA`.\n* `initFF(availableFlags, buildProvidedFlags)` takes EVA's own available and build sets\n  as arguments — EVA reports them over `postMessage`\n  (`CommunicationService.featureFlagsFromEva$`).\n* `storeAndSend(flags)` persists and pushes the enabled set to EVA via\n  `sendUpdatedConfig`.\n* An always-on list forces certain flags true:\n+\n[source,typescript]\n----\nprivate readonly ALWAYS_ON_FLAGS = ['🎪'];\n----\n+\nThe single entry is an emoji sentinel, unioned into the fetched set and forced to `true`\nin `storeAndSend`. Intentional, not a stray character.\n* `reset()` clears the `initialized` guard — though the early return inside `initFF` is\n  commented out, so initialisation always re-runs.\n\nSee xref:iframe-messaging.adoc[iframe messaging] and\nxref:domains:admin.adoc[DEV Admin].\n\n== Persisting flags\n\n`storeState(settingKey, flags)` writes the enabled set to global settings:\n\n[source,typescript]\n----\nconfig.config_value.flags = flags.join(',');\nconfig.config_value.lastChange = +new Date();\nreturn this.globalSettingsService.edit(settingKey, config);\n----\n\nThis is an *environment-wide* change: every user's next `initFF()` picks it up, and as\nnoted above it invalidates everyone's session overrides.\n\n`changeFlag(flag, value)` is the session-only equivalent — it recalculates the table,\nflips one `exec` value and rewrites `sessionStorage`.\n\n== Parsing rules\n\n[source,typescript]\n----\nprivate parseRawFlags(flagString: string): FEATURE_FLAGS[] {\n  if (!flagString) return [];\n  let flags = flagString.split(',').map((f) => f.trim()).sort();\n  if (flags[0] === '') flags = [];\n  return flags as FEATURE_FLAGS[];\n}\n----\n\n* Comma-separated, no spaces (the README's convention; `trim()` tolerates them anyway).\n* Sorted, so ordering in the source string is irrelevant.\n* An empty string yields an empty array, and a leading empty element clears the whole\n  list — so `',A,B'` resolves to *no flags*, not two.\n* The cast to `FEATURE_FLAGS[]` is unchecked: a typo'd flag name in the environment\n  variable silently becomes a flag that nothing reads.\n\n== Setting flags per environment\n\n`FEATURE_FLAGS` is read from the shell at build time by `deployment/set-env.ts`:\n\n[source,typescript]\n----\nfeatureFlags: '${process.env.FEATURE_FLAGS ? process.env.FEATURE_FLAGS : ''}',\n----\n\nNeither `setup_template-dev.cfg` nor `setup_template-production.cfg` exports it, so build\nflags are empty unless the pipeline sets `FEATURE_FLAGS` explicitly. In practice flags\nare managed through *global settings*, not the build.\n\nFor local work, edit `featureFlags` in `src/environments/environment.ts` — remembering\nthat the file is regenerated by `setenv.sh`. See\nxref:ops:environments.adoc[environments].\n\n== Adding a flag\n\n. Add a member to `FEATURE_FLAGS` in `feature-flags.const.ts`.\n. Apply it — service check, directive, or `FeatureFlagGuard` with\n  `data.enabledByFeatureFlag`.\n. If it gates a menu item, wrap the item in `*appEnabledByFeatureFlag` *and* decide\n  whether the route itself should be blocked with `FeatureFlagGuard`.\n. Enable it per environment through DEV Admin (`/admin/feature-flags`) → save to global\n  settings.\n. Document it in\n  xref:reference:feature-flag-catalog.adoc[the flag catalogue].\n\n== Gotchas summary\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Global settings beat build flags\n| The exec set is seeded from global settings.\n\n| Editing the setting resets session overrides\n| Via the `lastChange` comparison.\n\n| `isEnabled` needs `initFF` to have run\n| It reads build flags + session storage only.\n\n| The directive evaluates once\n| No live re-render on toggle.\n\n| Two similarly named guards\n| `FeatureFlagGuard` blocks; `FeatureFlagsGeneralGuard` initialises.\n\n| Flag typos are silent\n| The cast to `FEATURE_FLAGS[]` is unchecked.\n\n| Leading comma clears the list\n| `parseRawFlags` empties the array if the first element is `''`.\n\n| Session storage is per tab\n| Two tabs can disagree.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:events.adoc","title":"Events","description":"The domain event stream, and event rules — the automation layer that reacts to events by creating tasks, sending emails, editing suppliers or pushing to EventBridge.","url":"https://docx.payability.com/internal-admin/platform/events.html","navOrder":37,"source":"= Events\n:description: The domain event stream, and event rules — the automation layer that reacts to events by creating tasks, sending emails, editing suppliers or pushing to EventBridge.\n\n== Three different \"events\"\n\nDistinguish them before reading further:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Kind | Where\n\n| *Domain events*\n| Business facts emitted by backend services and stored in the event logger. Queryable,\n  visible in `/manage/events` and in the CRM timeline. This page.\n\n| *Event rules*\n| Stored rules that match a domain event, filter it, and run actions. Two generations.\n  This page.\n\n| *In-app events*\n| `TableEventsService`'s `Subject` bus, and iframe `postMessage` messages. See\n  xref:realtime.adoc#in-app-bus[the in-app event bus] and\n  xref:iframe-messaging.adoc[iframe messaging].\n|===\n\n== The domain event stream\n\nEvents live in the *event logger*, of which two generations are configured:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Base URL | Role\n\n| `eventLoggerUrl`\n| v2. `es-changelog` (the CRM timeline's event stream) and `track` (the analytics\n  endpoint used by `MonitorService`).\n\n| `eventRulesOldUrl`\n| v1. `event_rules/` CRUD, `event_rules/history/`, and a raw Elasticsearch endpoint at\n  `es` against the index `em_events`.\n|===\n\n=== Event taxonomy\n\n`manage/event-rules/event-rule.models.ts` declares the vocabulary a rule can match:\n\n[source,typescript]\n----\nexport const eventTypes = [\n  'analitics',            // sic — the analytics stream MonitorService writes to\n  'api-event',\n  'cli-event',\n  'event',\n  'messaging-log',\n  'messaging-result',\n  'task-change-event',\n  'underwriting-event',\n];\n\nexport const eventTargets = ['compliance', 'crm-task', 'email', 'marketplace', 'supplier'];\n----\n\nAn event therefore has a *type* (which subsystem produced it) and a *target type* (what\nkind of thing it is about), plus a `targetType`-scoped identifier — for a supplier, the\nsupplier key. That identifier is carried on requests as\n`X-EventLogger-TargetUUID` (or `X-Actions-TargetUUID` for CRM). See\nxref:domains:crm.adoc[CRM].\n\n[NOTE]\n====\n`'analitics'` is misspelled in the source and on the wire. `MonitorService` sends\n`X-EventLogger-Type: analitics`. Match the misspelling.\n====\n\n=== Browser-side Elasticsearch\n\n`EventsService` instantiates the `elasticsearch` npm client *in the browser*:\n\n[source,typescript]\n----\nthis.es = new Client({ host: environment.eventRulesOldUrl + 'es', log: 'warning' });\n\nprivate esRequest(params) {\n  return this.store.select('auth').pipe(take(1), switchMap((authState) =>\n    observableFrom(this.es.search(Object.assign({\n      index: 'em_events', type: 'em_events',\n      headers: { 'X-Session-Token': authState.user.jwtToken },\n    }, params)))));\n}\n\nquery(query = { match_all: {} }, limit = 30, page = 1, sort = { created_at: 'desc' })\n----\n\nThis is the only direct Elasticsearch client in the application, and the reason\n`elasticsearch` appears in `allowedCommonJsDependencies`.\n\n[IMPORTANT]\n====\nElasticsearch querying is currently *disabled* in the rule editor. `SelectorFormComponent`\nreplaces the live query with a stub and toasts the user:\n\n[source,typescript]\n----\nconst events$ = of({ hits: { total: { value: 0 }, hits: [] } }).pipe(\n  first(),\n  tap(() => this.toastrService.info('Elasticsearch is disabled. No events will be shown.')),\n);\n// the real query is commented out, marked \"Deprecated. Only for historical purposes.\"\n----\n\nSo the \"example matching events\" preview in the rule editor always shows zero. The\n`toElasticSearchQuery` builder and `EventsService.query` remain in the code, unused.\n====\n\n`/manage/events` (`manage/events/` with `event-badge/`, `event-filters/`,\n`event-preview/`) is the raw stream browser. The CRM timeline's `events` stream comes from\n`eventLoggerUrl es-changelog` instead — and requests `limit=2000000`, because there is no\npaging on that endpoint. See xref:domains:crm.adoc[CRM].\n\n== Event rules\n\nA rule is: *when an event of this type about this kind of target matches these filters,\ndo these actions*.\n\n[source,typescript]\n----\nexport interface EventRule extends Entity {   // Entity = { uuid, created_at }\n  eventType: string;         // one of eventTypes\n  eventTypeCode: string;\n  targetType: string;        // one of eventTargets\n  message: string;\n  user: string;\n  filters: Array<Filter>;\n  actions: Array<Action>;\n}\n----\n\n=== Filters\n\n`Filter` is the one piece of real parsing logic in the front end. Rules are authored as\n*expressions* and stored as structured objects.\n\n[source,typescript]\n----\nexport class Filter {\n  constructor(public name: string, public op: string, public val: string | number) {}\n  static regex = /([a-zA-Z.]+ ) *(!=|=|<|>|>=|<=|=<|=>) *([^ ]+)/;\n  static fromExpression(text) { … }   // \"amount >= 100\"  → Filter('amount', 'gte', '100')\n  static fromJson({ name, op, val }) { … }\n  toExpression() { … }                // Filter('amount','gte','100') → \"amount >= 100\"\n}\n----\n\nOperator mapping:\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Expression | Stored `op` | Rendered back as\n\n| `=`  | `eq`  | `=`\n| `!=` | `ne`  | `!=`\n| `<`  | `lt`  | `<`\n| `>`  | `gt`  | `>`\n| `>=` or `=>` | `gte` | `>=`\n| `<=` or `=<` | `lte` | `=<`\n|===\n\n[CAUTION]\n====\nThe round trip is *not* lossless. `>=` and `=>` both parse to `gte`, and `<=` and `=<`\nboth parse to `lte`; `toExpression` renders `gte` as `>=` but `lte` as `=<`. A rule saved\nwith `<=` reappears in the editor as `=<`. Cosmetic, but it makes diffs of rule text\nnoisy.\n\nThe regex also requires a *trailing space* in the field-name group\n(`([a-zA-Z.]+ )`), so `amount>=100` does not parse and an unparseable expression throws\n`Can't parse <text>`. The form validator uses the same regex\n(`Validators.pattern(Filter.regex)`) so the error surfaces as invalid input rather than\nan exception.\n\nField names may contain dots (`data.target_type`), which is how nested event properties\nare addressed.\n====\n\n=== Actions\n\n[source,typescript]\n----\nexport class Action {\n  static CREATE_TASK           = 'create-task';\n  static SEND_EMAIL            = 'send-email';\n  static SLACK_NOTIF           = 'notify-on-slack';       // ← not in actionTypes\n  static EDIT_SUPPLIER         = 'edit-supplier';\n  static SEND_TO_EVENT_BRIDGE  = 'push-to-event-bridge';\n\n  type: string;\n  template: string;\n  to?: string;\n  contextMapping: object;\n  dataMapping: object;\n  data: object;\n  delay: number;\n  delayTime: number;\n  delayPeriod: number;\n  delayEnabled: boolean;\n}\n\nexport const actionTypes = [\n  Action.CREATE_TASK,\n  Action.SEND_EMAIL,\n  // Action.SLACK_NOTIF,        ← commented out\n  Action.EDIT_SUPPLIER,\n  Action.SEND_TO_EVENT_BRIDGE,\n];\n----\n\nFour active action types; Slack notification is defined but *commented out of the\nselectable list*. The constant remains, so existing rules of that type would still load.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Action | Editor | Behaviour\n\n| `create-task`\n| `action-form/create-task/`\n| Creates a CRM task. See xref:domains:crm.adoc[CRM].\n\n| `send-email`\n| `action-form/send-email/`\n| Sends a templated email — the origin of most `AUTO_EMAIL` traffic. See\n  xref:domains:emails.adoc[emails].\n\n| `edit-supplier`\n| `action-form/edit-supplier/`\n| Mutates supplier fields.\n\n| `push-to-event-bridge`\n| `action-form/send-to-event-bridge/`\n| Publishes to AWS EventBridge, extending automation beyond this system.\n|===\n\n=== Mappings\n\nThree fields shape an action's payload:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `contextMapping`\n| `{ propName → selector }` — pull a value out of the *event* into the action's context.\n\n| `dataMapping`\n| `{ propName → value }` — map event data onto action data.\n\n| `data`\n| Static data attached to the action.\n|===\n\n`RulesUtilsService` builds these as form groups with an *all-or-nothing* validator: within\na mapping pair, if any field is filled, all must be:\n\n[source,typescript]\n----\nstatic addAllOrNothingValidator(group: FormGroup) {\n  // for each control: if any sibling is non-empty and this one is empty → { required: true }\n  // and when this one becomes non-empty, revalidate the empty siblings\n}\n----\n\n`tryAddNext` appends a fresh blank row when the last row is filled — the \"grow as you\ntype\" list pattern used for filters, mappings and recipients. Email recipients use\n`emailOrEmpty`: validate as an email *unless* blank.\n\n=== Delays\n\nAn action can be delayed. The wire format is a single `delay` in **seconds**; the editor\nsplits it into a magnitude and a unit:\n\n[source,typescript]\n----\nexport const MINUTES = 60;\nexport const DAYS    = 24 * 60 * 60;\nexport const WEEKS   = 7 * 24 * 60 * 60;\n----\n\nOn load (`RulesAddEditComponent`), the seconds value is decomposed largest-unit-first:\n\n[source]\n----\ndelay > 0                → delayEnabled = true\ndelay % WEEKS   === 0    → delayPeriod = WEEKS,   delayTime = delay / WEEKS\nelse delay % DAYS === 0  → delayPeriod = DAYS,    delayTime = delay / DAYS\nelse                     → delayPeriod = MINUTES, delayTime = delay / MINUTES\n----\n\n[CAUTION]\n====\n`MINUTES = 60` is the fallback, so a delay that is not a whole number of weeks or days is\nexpressed in minutes — and a delay of, say, 90 seconds becomes `delayTime = 1.5`\nminutes. There is no hours unit and no seconds unit. Round-tripping a delay authored\noutside this UI can produce fractional values.\n====\n\n=== EventBridge action naming\n\n[source,typescript]\n----\nexport const PREFIX_EVNET_RULE = 'em_event_rule_';   // sic\n\nexport function eventBridgeActionNameValidator(control: AbstractControl) {\n  return control.value.substring(0, PREFIX_EVNET_RULE.length) !== PREFIX_EVNET_RULE\n    ? { eventRuleNameError: true } : null;\n}\n----\n\nEventBridge action names must begin with `em_event_rule_`. The constant name is\nmisspelled (`EVNET`); the *value* is correct.\n\n== The rule editor\n\n`/manage/event-rules` with:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route | Component\n\n| `list`\n| `RulesListComponent`\n\n| `add`, `:eventRuleId/edit`\n| `RulesAddEditComponent` — one component for both, distinguished by the parameter\n\n| `:eventRuleId/history`\n| `RulesHistoryListComponent`\n|===\n\n`RulesAddEditComponent` composes two child forms:\n\n[source]\n----\nselectorForm = new FormGroup({})     ← SelectorFormComponent registers itself into it\nactionsForm  = new FormArray([])     ← ActionsFormComponent contributes actions\n\nngAfterViewInit:\n  combineLatest(selector.selectorValue$, actions.value$, sendButton$)\n    → { ...selector, actions }\n    → isEditing ? eventsService.save(uuid, rule) : eventsService.create(rule)\n    → navigate to manage/event-rules\n----\n\n[WARNING]\n====\nThe save is wired as a `combineLatest` over the two form streams *and* a\n`sendButton$` subject, inside a long-lived subscription. Because `combineLatest` re-emits\nwhenever any source emits, a subsequent form change after a click can trigger *another*\nsave. Treat this subscription carefully if you touch the component.\n\n`selectorValue$` itself is `valueChanges` filtered on `form.valid`, debounced 500 ms and\n`distinctUntilChanged` — so only valid selector states reach the save path.\n====\n\n`SelectorFormComponent` uses `ViewEncapsulation.None`, so its styles are global.\n\nLoading a rule for edit converts stored filters back to expressions\n(`Filter.fromJson(f).toExpression()`) and decomposes action delays, as above.\n\n=== Rule history\n\n`EventsService.historyList(uuid, limit = 10, last?)`:\n\n[source]\n----\nGET <eventRulesOldUrl>event_rules/history/\n  ?limit=10\n  &order_by={\"field\":\"created_at\",\"direction\":\"desc\"}\n  &q={\"filters\":[{\"name\":\"event_rule_uuid\",\"op\":\"eq\",\"val\":\"<uuid>\"}]}\n  [&last=…]\n----\n\nNote the query language is the *same* `{ name, op, val }` filter shape as rule filters —\nso `Filter` describes both what a rule matches and how the API is queried.\n\n=== Rule CRUD\n\nAll on `eventRulesOldUrl` under `event_rules/`, each with an `X-InstanceId: 8001` header:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Method | Endpoint\n\n| `create` | `POST event_rules/`\n| `get` | `GET event_rules/:uuid/`\n| `list` | `GET event_rules/`\n| `save` | `PUT event_rules/:uuid/`\n| `delete` | `DELETE event_rules/:uuid/`\n| `historyList` | `GET event_rules/history/`\n|===\n\nResponses are enveloped as `Response<T>` / `ListResponse<T>`\n(`{ data, meta }`). The `X-InstanceId` value is hardcoded `'8001'` in this service rather\nthan read from `env.instanceId` — a divergence from `CrmService` and\n`GlobalSettingsService`, which use the config value (also 8001).\n\n== Event rules v2\n\n`/manage/event-rules-2` (`EventRules2Component`) is the beta rewrite. The component in\nthis repository does almost nothing:\n\n[source,typescript]\n----\nthis.userService.checkPermission(USER_PERMISSION.CRM_ADMIN)\n  .subscribe((permitted) => { this.hasCRMPermission = permitted; this.cd.detectChanges(); });\n----\n\nIt resolves `CRM_ADMIN` and renders accordingly — the editor itself is elsewhere. The\nsidebar labels it \"Event Rules v2.0 (beta)\".\n\nBoth generations are live and reachable. Determine which one a change belongs to before\nstarting.\n\n== Permissions\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Permission | Governs\n\n| `INT_RULES_UPDATE`\n| Editing event rules.\n\n| `CRM_ADMIN`\n| The v2 editor, and CRM administration generally.\n|===\n\n`/manage` denies `REST_UW_ACCESS` and `ADVANCE_AUDITORS` at the parent route.\n\n== Gotchas summary\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Elasticsearch preview is disabled\n| Always zero example events, with an info toast.\n\n| `'analitics'` is misspelled\n| On the wire too.\n\n| Filter round-trip is lossy\n| `<=` becomes `=<`.\n\n| Filter regex needs a trailing space\n| `amount>=100` does not parse.\n\n| Slack action is defined but unselectable\n| Commented out of `actionTypes`.\n\n| Delay units are minutes/days/weeks only\n| No hours; fractional minutes are possible.\n\n| `PREFIX_EVNET_RULE` is misspelled\n| The constant, not the value.\n\n| Two rule generations\n| v1 in this repo, v2 largely elsewhere.\n\n| `combineLatest` save\n| Can re-fire on later form changes.\n\n| Hardcoded instance id\n| `'8001'` literal in `EventsService`.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:realtime.adoc","title":"Real-time streams (SSE)","description":"Server-sent events for long-running job progress, the in-app publish/subscribe bus, and the polling that fills the gaps.","url":"https://docx.payability.com/internal-admin/platform/realtime.html","navOrder":38,"source":"= Real-time streams (SSE)\n:description: Server-sent events for long-running job progress, the in-app publish/subscribe bus, and the polling that fills the gaps.\n\nThree mechanisms deliver \"something changed\" without a page reload. They are unrelated\nto each other and to xref:events.adoc[domain events].\n\n== 1. Server-sent events\n\n=== Why a polyfill\n\nNative `EventSource` cannot set request headers, and every Payability backend requires\n`X-Session-Token`. `SseService` therefore uses `EventSourcePolyfill` from\n`ng-event-source`:\n\n[source,typescript]\n----\n@Injectable()\nexport class SseService {\n  private token: string;\n\n  constructor(private zone: NgZone, private store: Store<AppState>) {\n    this.store.select('auth').subscribe((authState) => {\n      if (authState && authState.user) { this.token = authState.user.jwtToken; }\n    });\n  }\n\n  getStream(sseEndpointUrl: string): Observable<any> {\n    return new Observable<any>((observer) => {\n      const eventSource = new EventSourcePolyfill(sseEndpointUrl, {\n        headers: { 'X-Session-Token': this.token },\n      });\n\n      eventSource.onerror   = (error) => observer.error(error);\n      eventSource.onmessage = (event) => {\n        try {\n          const data = JSON.parse(event.data);\n          this.zone.run(() => observer.next(data));     // ← back into Angular's zone\n        } catch (error) { Logger.log(error); observer.error(error); }\n      };\n\n      return () => { Logger.log('SseService - eventSource.close()'); eventSource.close(); };\n    });\n  }\n}\n----\n\nPoints worth knowing:\n\n* `NgZone.run` is required — polyfilled `EventSource` callbacks arrive outside Angular's\n  zone, so without it change detection never runs and the UI appears frozen.\n* The token is captured from a *long-lived* store subscription, so a stream opened before\n  login carries `undefined`.\n* Any `onerror` **terminates the observable**. There is no reconnect logic; the browser's\n  own `EventSource` retry is bypassed because the error is propagated as an observable\n  error.\n* A JSON parse failure also terminates the stream, not just the message.\n* Teardown closes the connection, so an unsubscribed stream is cleaned up.\n\n[CAUTION]\n====\nA single malformed message or a transient network blip kills the stream permanently. The\nUI will simply stop updating with no visible error unless the caller handles it. If you\ndepend on a stream staying alive, add your own retry (`retryWhen`) at the call site.\n====\n\n=== The one consumer: B2B task monitoring\n\n`B2bWorkflowService` (`suppliers/services/workflow/`) wraps the B2B task manager\n(`workflowApiUrl`, correlated by `workflowGciId`):\n\n[source,typescript]\n----\ntaskMonitoring(): Observable<any> {\n  const endpointURL = `${this.apiUrl}tasksMonitoring`;\n  return !isUndefined(this.streamShared$) ? this.streamShared$ : this.initStream(endpointURL);\n}\n----\n\nThe stream is `share()`d so that many subscribers use one connection, and each message is\nalso pushed onto an internal `Subject` (`handler`).\n\n`subscribeTask(taskName, callback, searchForInclusion = false)` filters that subject by\ntask name — exactly, or by regex search when `searchForInclusion` is true.\n\n[WARNING]\n====\n`subscribeTask` subscribes to `taskMonitoring()` and then, *inside* that subscription's\n`next` handler, subscribes to `this.handler`. Every stream message therefore creates a new\ninner subscription to `handler`, and none of them are torn down. Callbacks accumulate and\nfire repeatedly as the number of received messages grows.\n\n`pendingSubs` is incremented but never read; two `finalize(() => {})` calls are no-ops;\n`streamShared$` is never reset. Treat `subscribeTask` as known-broken for long-lived\nmonitoring and prefer subscribing to `taskMonitoring()` directly with your own `filter`.\n====\n\nOther methods on the same service:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Detail\n\n| `getLastExecution(name?)`\n| `GET getLastExecution?gci=<gci>[&name=…]` — the last run of a named job.\n\n| `getListOfActivities(id)`\n| `GET getListOfActivities?id=<encoded>&gci=<gci>` — the steps of one run.\n\n| `executeWorkflow(data)`\n| `POST executeWorkflow?gci=<gci>` with\n  `Content-Type: application/x-www-form-urlencoded`, hand-encoding the body with\n  `encodeURIComponent`. The only form-urlencoded request in the application.\n\n| `downloadFile(name, url)` / `downloadPdfFile(name, url)`\n| Accept either a `Blob` or a URL; fetch with `responseType: 'blob'` and save via\n  `file-saver` as `.txt` or `application/pdf`.\n|===\n\n=== Who uses it\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Feature | Use\n\n| Load Earnings / Load Remittance\n| The supplier Actions menu operations return immediately; progress arrives on the\n  stream. See xref:domains:supplier-lifecycle.adoc[lifecycle actions].\n\n| Bulk imports\n| `BatchImportService.getLastExecution` / `getListOfActivities` delegate to this service\n  for job status. See xref:domains:manage.adoc#bulk[bulk actions].\n|===\n\n[[in-app-bus]]\n== 2. The in-app event bus\n\n`{source-root}/shared/table-events.service.ts` is a small `Subject`-based\npublish/subscribe bus, used where a store action would be heavier than the problem\nwarrants:\n\n[source,typescript]\n----\nexport const ADD_TRANSACTION_EVENT = 'ADD_TRANSACTION_EVENT';\nexport const CRM_OPEN_ALL_TAB      = 'CRM_OPEN_ALL_TAB';\nexport const SIDENAV_CLOSED_EVENT  = 'SIDENAV_CLOSED_EVENT';\nexport const TASK_COUNTERS_UPDATE  = 'TASK_COUNTERS_UPDATE';\n\n@Injectable()\nexport class TableEventsService {\n  private listeners = {};\n  private events = new Subject();\n\n  constructor() {\n    this.events.subscribe(({ name, args }) => {\n      if (this.listeners[name]) { for (const l of this.listeners[name]) { l(...args); } }\n    });\n  }\n\n  on(name, listener)            { … }   // register\n  broadcast(name, ...args)      { … }   // emit\n}\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Event | Broadcast by → consumed by\n\n| `TASK_COUNTERS_UPDATE`\n| `CrmEffects` after a task mutation → `TaskCountBadgeComponent` reloads the sidebar\n  counters.\n\n| `CRM_OPEN_ALL_TAB`\n| `CrmEffects` after adding a CRM item → the CRM panel switches to the \"All\" tab.\n\n| `ADD_TRANSACTION_EVENT`\n| Transaction dialogs → transaction grids refresh.\n\n| `SIDENAV_CLOSED_EVENT`\n| Sidebar → layout listeners.\n|===\n\n[CAUTION]\n====\n`on()` has **no unsubscribe**. Listeners registered by a component are never removed, so\na component that registers in `ngOnInit` leaks a callback on every instantiation. Register\nonce (in a service or a singleton component), or prefer the store.\n\nAlso note the constructor subscribes to `events` and never unsubscribes, and errors thrown\nby a listener will propagate through the shared `Subject` and kill it for *all*\nlisteners.\n====\n\nOther `Subject`-based buses exist for narrower purposes:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Purpose\n\n| `CancelEmitService`\n| Supplier cancellation / win-back change notifications. See\n  xref:domains:supplier-lifecycle.adoc[lifecycle actions].\n\n| `CardFormgroupMessageService`\n| Save/error feedback for the editable-card pattern.\n\n| `CommunicationService`\n| The iframe `postMessage` channel — see\n  xref:iframe-messaging.adoc[iframe messaging].\n\n| `PendingInterceptorService`\n| In-flight request count for the global spinner.\n|===\n\n== 3. Polling\n\nWhere neither SSE nor the bus applies, a few features poll.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Feature | Mechanism\n\n| Task counters\n| `TaskCountBadgeComponent` reloads every `RELOAD_COUNT_INTERVAL = 60000` ms, *and*\n  listens to `document`'s `visibilitychange` so it only refreshes while the tab is\n  visible. It also reloads on `TASK_COUNTERS_UPDATE`. Three triggers for one counter.\n\n| Batch status\n| `PaymentBatchRepositoryService.getStatus(batchId)` is called on demand by the batch\n  screens rather than streamed. See xref:domains:processes.adoc[processes].\n\n| Version check\n| `VersionService` compares front-end and back-end versions (using `semver`) for the\n  sidebar footer and the login screen. See xref:observability.adoc#versions[versions].\n|===\n\n== Choosing a mechanism\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Need | Use\n\n| Progress of a long backend job\n| `B2bWorkflowService.taskMonitoring()` — subscribe directly and filter yourself.\n\n| \"Refresh yourself\" between sibling components\n| `TableEventsService`, registering the listener once.\n\n| Shared state more than one screen reads\n| The NgRx store. See\n  xref:architecture:state-management.adoc[state management].\n\n| Cross-application (EVA) notification\n| The `postMessage` protocol. See\n  xref:iframe-messaging.adoc[iframe messaging].\n|===\n\nDo not add a new polling interval without checking whether the SSE stream already carries\nthe information.\n\n== Gotchas summary\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| SSE has no reconnect\n| Any error or parse failure ends the stream silently.\n\n| `NgZone.run` is mandatory\n| Polyfilled callbacks are outside Angular's zone.\n\n| `subscribeTask` leaks\n| Nested subscription per received message.\n\n| `TableEventsService.on` cannot unsubscribe\n| Listeners accumulate.\n\n| A throwing listener kills the bus\n| One shared `Subject`, no per-listener isolation.\n\n| Token captured by subscription\n| A stream opened pre-login sends `undefined`.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:iframe-messaging.adoc","title":"Iframe messaging and EVA","description":"The micro-front-end seam — how EVA is embedded, authenticated, configured and resized, and the full postMessage protocol.","url":"https://docx.payability.com/internal-admin/platform/iframe-messaging.html","navOrder":39,"source":"= Iframe messaging and EVA\n:description: The micro-front-end seam — how EVA is embedded, authenticated, configured and resized, and the full postMessage protocol.\n\n== What EVA is\n\n*EVA* is a separate Payability front end. Parts of Internal Admin are not rendered by\nthis codebase at all: they are EVA pages loaded in iframes, integrated over a typed\n`postMessage` protocol.\n\nThis is a deliberate architectural seam — see\nxref:architecture:decisions.adoc[decision D9]. It means:\n\n* Some UI bugs belong to another repository.\n* State that both applications need is *synchronised explicitly*, in both directions.\n* The `MESSAGE_TYPE` enum is a cross-repository contract.\n\n== Where it lives\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Concern | Location\n\n| Iframe plumbing\n| `shared/iframe/` — `communication/communication.service.ts`,\n  `communication-message.model.ts`, `message-type.enum.ts`,\n  `iframe-communication.const.ts`, `iframe.component.ts`, `iframe.module.ts`,\n  `resize/resize-iframe.service.ts`, `resize/resize-iframe.model.ts`\n\n| EVA integration\n| `shared/eva/` — `eva.component.ts`, `eva.service.ts`,\n  `eva-communication.service.ts`, `const/eva.ts`,\n  `const/eva-iframe-selector.enum.ts`, `models/eva-permissions.model.ts`\n\n| Parent-side handling\n| `{source-root}/app.component.ts`\n\n| Helper\n| `shared/utils/communication-commands.util.ts`\n|===\n\n== Embedded surfaces\n\n`EVA_IFRAME_SELECTOR` enumerates every host element, and therefore every EVA-rendered\nregion:\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Enum member | Host selector | Surface\n\n| `TOP_BAR`\n| `app-supplier-box-header-eva`\n| The supplier header bar. Flag `EVA_SUPPLIER_MENU`.\n\n| `TRACKER`\n| `app-tracker`\n| The underwriting tracker. Flag `UNDERWRITING_TRACKER`. See\n  xref:domains:underwriting-tracker.adoc[UW tracker].\n\n| `ONBOARDING_DETAILS`\n| `app-supplier-onboarding-details`\n| Onboarding summary. Flag `EVA_ONBOARDING_SUMMARY`.\n\n| `UNDERWRITING_BETA`\n| `app-supplier-underwriting-beta`\n| The beta underwriting view.\n\n| `SUPPLIER_CONTRACTS`\n| `app-supplier-contracts`\n| Supplier contracts tab.\n\n| `CONTRACTS`\n| `app-contracts`\n| Manage → contracts.\n\n| `EVENT_RULES`\n| `app-event-rules-2`\n| Event rules v2 editor.\n\n| `FEATURE_FLAGS`\n| `app-admin-feature-flags`\n| DEV Admin flag console (embeds EVA to manage EVA's own flags).\n\n| `QA`\n| `app-admin-qa`\n| DEV Admin QA surface.\n|===\n\n`EvaService.getIframeIdBySelector(selector)` resolves a selector to the iframe's DOM id —\nthat is how the parent addresses a specific frame without holding a component reference.\n\n== Embedding an EVA page\n\n[source,html]\n----\n<app-eva [url]=\"'underwriting-tracker'\" [queryParams]=\"{ supplierKey: key }\"></app-eva>\n----\n\n`EvaComponent` (`shared/eva/eva.component.ts`) does four things on init:\n\n. Reads the session directly from `localStorage['currentUser']` — not from the store.\n. Resolves the user's *sub-permissions* via\n  `UsersService.getPermissionSubPerms(permCode)` and calls `setConfig(subPermissions)`.\n. Builds the iframe URL through `EvaService.getUrl(url, params)`.\n. Registers its iframe id with `EvaCommunicationService.setIframeId(...)`.\n\nIt also clears the EVA auth cookie on `window:unload` and `window:beforeunload`, so a\nrefresh re-issues the token.\n\n== Authentication hand-off\n\n`EvaService` has two modes, and they differ enough to matter:\n\n[source,typescript]\n----\ngetUrl(subUrl, queryParams = {}): string {\n  if (this.getCurrentHostname() === 'localhost') {\n    return `${environment.evaApiUrl}${subUrl}?auth=${this.token}&${queryParamsString}`;\n  }\n  return `${this.evaUrl}${subUrl}?${queryParamsString}`;\n}\n\nprivate passTokenToEva(): void {\n  if (this.getCurrentHostname() === 'localhost') return;\n  if (this.cookieService.doesCookieExist(EVA_COOKIE_TOKEN_KEY)) {\n    this.cookieService.delete(EVA_COOKIE_TOKEN_KEY, { path: '/', domain: environment.domain });\n  }\n  this.cookieService.setCookie(EVA_COOKIE_TOKEN_KEY, this.token,\n    { path: '/', domain: environment.domain, expires: 1 });\n}\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Environment | Mechanism\n\n| Deployed\n| A cookie named `PYBEvaToken` on `environment.domain` — the *shared parent domain*, so\n  both applications can read it. Expires in 1 day.\n\n| `localhost`\n| The token is appended to the iframe URL as `?auth=<jwt>`, because the two dev servers\n  are on different origins and cannot share a cookie.\n|===\n\n[CAUTION]\n====\nLocal and deployed behaviour differ in *where the token lives*. A bug that only reproduces\nlocally (or only when deployed) is often this. On `localhost` the JWT also appears in the\niframe URL, and therefore in the browser's history and in any logging of iframe `src`.\n====\n\n== The `postMessage` layer\n\n`CommunicationService` is the transport. Key properties:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Concern | Implementation\n\n| Addressing\n| Every message carries a `pluginIframeId`. Children read theirs from the\n  `?pluginIframeId=` query parameter; the parent keeps a `childs: IframeChild[]`\n  registry (`{ iframeId, domain, url, window }`).\n\n| Envelope\n| `{ pluginIframeId, payload: { type: MESSAGE_TYPE, data? } }`. Inbound messages are\n  widened to `MessageFromIframe` with a `source` of `PARENT` or `CHILD`.\n\n| Streams\n| `incomingMessage$` merges `messageFromParent$` and `messageFromChild$`, tagged by\n  source and `share()`d. A separate `featureFlagsFromEva$` subject carries EVA's flag\n  list.\n\n| Origin checks\n| Inbound: `checkOrigin` (parent messages) and `checkChildOrigin` (child messages,\n  compared against `new URL(child.url).origin`). `localhost` is always allowed when\n  `environment.production` is false.\n\n| Target origin\n| Outbound uses `'*'` on localhost, otherwise the parent frontend domain (for\n  `sendMessageToParent`) or the child's origin (for `sendMessageToChild`).\n\n| `document.domain`\n| `setDomain()` assigns `document.domain = environment.domain` before every send, wrapped\n  in an empty `catch`. A legacy same-origin relaxation.\n\n| Debug logging\n| Every send and receive is `console.log`ged as `[IA] Sending`/`[IA] Received` when\n  `environment.production` is false — the fastest way to debug the protocol.\n|===\n\n[WARNING]\n====\n`IframeModule` provides the parent-domain token as `'*'`:\n\n[source,typescript]\n----\n{ provide: IFRAME_COMMUNICATION_PARENT_FRONTEND_DOMAIN, useValue: '*' }\n----\n\nSo `checkOrigin` compares an inbound origin against `'*'` as a *literal string*, which\nnever matches, and `sendMessageToParent` targets `'*'`. The child-side origin checks\n(`checkChildOrigin`) are real, because they derive the expected origin from the\nregistered child's URL. Do not read the `'*'` value as a validated wildcard.\n\n`document.domain` assignment is deprecated in modern browsers and will eventually stop\nworking.\n====\n\n== Message catalogue\n\n`shared/iframe/communication/message-type.enum.ts`. Values are human-readable strings like\n`'[GENERAL] Config Request'`.\n\n=== Configuration\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Type | Direction | Meaning\n\n| `GENERAL__CONFIG_REQUEST`\n| EVA → IA\n| EVA asks for its configuration.\n\n| `GENERAL__CONFIG_RESPONSE`\n| IA → EVA\n| `{ flags: string[], permissions: EvaPermissions }` — sent by\n  `EvaCommunicationService.sendUpdatedConfig`.\n\n| `GENERAL__CONFIG_UPDATE`\n| EVA → IA\n| EVA reports its own available/build flags.\n\n| `FEATURE_FLAGS__RESET`\n| IA → EVA\n| Clear EVA's flag overrides.\n|===\n\n`EvaPermissions` is `{ permission, subPermissions: string[] }` — EVA's authorisation is\nderived from what this app sends. See xref:authorization.adoc[authorisation].\n\n=== Navigation\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Type | Direction | Meaning\n\n| `GENERAL__NAVIGATE_BY_LINK`\n| EVA → IA\n| `router.navigate([data.url])` in the parent.\n\n| `GENERAL__ONLY_CHANGE_ROUTE`\n| EVA → IA\n| Same handler in `AppComponent` — currently indistinguishable in behaviour from\n  `NAVIGATE_BY_LINK`.\n\n| `GENERAL__CHANGE_PARENT_ROUTE`\n| IA → EVA\n| Built by `changeParentRouteCommand(url)`; sent to the top bar on every\n  `NavigationEnd` so EVA knows where the parent is.\n\n| `MAIN_HEADER__NAVIGATE_BY_DEAL_LINK`\n| EVA → IA\n| Jump to the external sales CRM using `extDealId`.\n|===\n\n=== Supplier state\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Type | Direction | Meaning\n\n| `GET_STORE__SUPPLIER_DETAILS`\n| EVA → IA\n| EVA asks for supplier details. `AppComponent` waits until\n  `selectSupplierDetails` matches the requested `supplierKey`, then replies once.\n\n| `UPDATE_EVA_STORE__SUPPLIER_DETAILS`\n| IA → EVA\n| Push supplier details into EVA's store. Sent on *every* change via\n  `EvaCommunicationService.updateSupplierDetailsStore`.\n\n| `GENERAL__UPDATE_SUPPLIER_DETAILS_STORE`\n| EVA → IA\n| EVA patches supplier details in *this* app's store, dispatching\n  `UpdateSupplierDetailsModelByEva`.\n\n| `MAIN_HEADER__REFETCH_SUPPLIER_DETAILS`\n| IA → EVA\n| Ask EVA's header to re-fetch.\n\n| `SUPPLIER_MENU__GET_MENU_CONDITIONS`\n| both\n| EVA asks; IA replies with `{ isAmazonLending, isSellerCard }` computed from\n  `supplierDetails.isAmazonSeller > 0` and whether any payment method is `EFS` or\n  `pcardAvailable`.\n|===\n\n[IMPORTANT]\n====\nSupplier details flow *both ways*: `AppComponent` pushes on every store change, and EVA can\npatch back via `GENERAL__UPDATE_SUPPLIER_DETAILS_STORE`. A field added to\n`SupplierDetailsModel` must be handled on both sides, and a change made in either\napplication propagates to the other. See xref:domains:suppliers.adoc[suppliers].\n====\n\n=== Actions\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Type | Direction | Meaning\n\n| `MAIN_HEADER__LOGIN_AS_SUPPLIER`\n| EVA → IA\n| Trigger the login-as-supplier flow (needs the browser extension).\n\n| `MAIN_HEADER__LOGIN_TO_AMAZON`\n| EVA → IA\n| Trigger the Amazon login flow.\n\n| `ONBOARDING__INIT_LOAD_EARNINGS_PROCESS`\n| EVA → IA\n| Start an earnings load. See\n  xref:domains:supplier-lifecycle.adoc[lifecycle actions].\n\n| `CONTRACTS__REFRESH`\n| EVA → IA\n| Ask the parent to refresh the contracts page.\n\n| `GENERAL__SHOW_TOAST`\n| EVA → IA\n| `{ type, message }` — `'success'` shows a success toast, anything else an error toast.\n|===\n\n=== Sizing\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Type | Effect\n\n| `RESIZE_IFRAME_SIZE`\n| Set both height and width.\n\n| `RESIZE_IFRAME_WIDTH_SIZE` / `RESIZE_IFRAME_HEIGHT_SIZE`\n| Set one dimension.\n\n| `RESET_IFRAME_SIZE`\n| Back to `100%` for both.\n\n| `FULL_SIZE_TOP_BAR` / `REVERT_TOP_BAR_SIZE`\n| Expand and restore the supplier top bar.\n|===\n\n`ResizeIframeService` (`providedIn: 'root'`, extends `SubscriberComponent`) filters\n`incomingMessage$` for the four resize types and applies them:\n\n[source,typescript]\n----\nprivate getElement(item: ResizeItem): ResizeHTMLElement {\n  const result = item.differentFrameSelector\n    ? document.querySelector(`${item.differentFrameSelector} iframe`)\n    : document.getElementById(item.id);\n  if (!result) throw new Error('Element not found!');\n  return result as ResizeHTMLElement;\n}\n----\n\n`differentFrameSelector` lets one iframe resize *another* — used when a modal inside one\nEVA frame needs the containing frame to grow.\n\n[CAUTION]\n====\n`getElement` throws `'Element not found!'` when the target is missing. The throw happens\ninside the `incomingMessage$` subscription, which will terminate the subscription and stop\n*all* subsequent resize handling for the page. A resize message arriving after its frame\nis destroyed therefore breaks resizing until reload.\n====\n\nThe repository README documents the symmetry: a same-named `ResizeIframeService` exists in\nboth applications, and in the default direction EVA sends dimensions to Internal Admin,\nwhich resizes the frame.\n\n== Parent-side handling\n\n`AppComponent.doActionIfMessage(message)` is the single inbound switch:\n\n[source]\n----\nGENERAL__SHOW_TOAST                        → toastr success | error\nGENERAL__UPDATE_SUPPLIER_DETAILS_STORE     → dispatch UpdateSupplierDetailsModelByEva\nGET_STORE__SUPPLIER_DETAILS                → reply with UPDATE_EVA_STORE__SUPPLIER_DETAILS\nGENERAL__NAVIGATE_BY_LINK                  → router.navigate\nGENERAL__ONLY_CHANGE_ROUTE                 → router.navigate\nSUPPLIER_MENU__GET_MENU_CONDITIONS         → reply with { isAmazonLending, isSellerCard }\n----\n\nResize messages are handled separately by `ResizeIframeService`; flag messages by\n`FeatureFlagsEvaService`.\n\n[NOTE]\n====\n`doActionIfEvaNeedsSupplierMenuCondition` pushes a *new* subscription into\n`this.subscriptions` every time the message arrives, and that subscription is only cleaned\nup when `AppComponent` is destroyed — i.e. never, in practice. Repeated menu-condition\nrequests accumulate subscriptions.\n====\n\n== Outbound helper\n\n`EvaCommunicationService` (provided *per `EvaComponent`*, not globally) implements the\noutbound command set:\n\n[source,typescript]\n----\ninterface EvaCommunicationCommands {\n  refetchSupplierDetails(): void;\n  sendUpdatedConfig(flags: string[], permissions?: EvaPermissions): void;\n  resetFeatureFlags(): void;\n  updateSupplierDetailsStore(details: SupplierDetailsModel): void;\n}\n----\n\n`updateSupplierDetailsStore` resolves the *top-bar* frame by selector rather than using the\ncomponent's own iframe id, and returns silently if that frame is absent.\n\n[NOTE]\n====\n`EvaCommunicationService` lives in `shared/` but imports `SupplierDetailsModel` from\n`suppliers/model/` — one of the two systematic `shared → feature` dependencies in the\ncodebase. See xref:architecture:overview.adoc[architectural overview].\n====\n\n== Debugging the protocol\n\n. Open the console on a non-production build. Every message is logged as\n  `[IA] Sending <type> <data> <target>` or `[IA] Received <type> <data>`.\n. If nothing is logged on receive, the message was dropped by\n  `checkIframeIdExists`, `checkOrigin`/`checkChildOrigin`, or the\n  `pluginIframeId` comparison.\n. If the frame is blank, check the token: cookie `PYBEvaToken` on\n  `environment.domain` when deployed, `?auth=` in the iframe URL on localhost.\n. If sizing is stuck, check whether a `'Element not found!'` error has already killed the\n  `ResizeIframeService` subscription.\n. If flags look wrong, check `sessionStorage['FeatureExecFlagsEva']` and the\n  `FEATURE_FLAGS_EVA` global setting. See xref:feature-flags.adoc[feature flags].\n\n== Changing the contract\n\n`MESSAGE_TYPE` is shared with the EVA repository. Adding a type is safe (the switch ignores\nunknown types); *changing or removing* one is a coordinated release across both\napplications. Add the new type, ship both sides, then remove the old one.\n\n== Gotchas summary\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Parent domain token is `'*'`\n| `checkOrigin` cannot match it.\n\n| Local vs deployed auth differs\n| Query parameter vs cookie.\n\n| Two navigation message types behave identically\n| `NAVIGATE_BY_LINK` and `ONLY_CHANGE_ROUTE`.\n\n| A missing resize target kills resizing\n| The thrown error terminates the subscription.\n\n| Menu-condition handler leaks subscriptions\n| One per received message.\n\n| `document.domain` is deprecated\n| Assigned before every send.\n\n| Supplier details are bidirectional\n| Changes must be handled on both sides.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:error-handling.adoc","title":"Error handling","description":"The layers that catch, parse, present and record errors — and why a server error often produces two toasts.","url":"https://docx.payability.com/internal-admin/platform/error-handling.html","navOrder":40,"source":"= Error handling\n:description: The layers that catch, parse, present and record errors — and why a server error often produces two toasts.\n\n== The layers\n\n[source]\n----\n┌─────────────────────────────────────────────────────────────────────┐\n│ window.onerror (main.ts)                                            │\n│   uncaught JS errors → Segment 'WINDOW ERROR' track, returns false  │\n├─────────────────────────────────────────────────────────────────────┤\n│ ErrorHandler → AppErrorHandler                                      │\n│   Angular's global handler. Dev: log + rethrow. Prod: log only.     │\n├─────────────────────────────────────────────────────────────────────┤\n│ PendingInterceptorService                                            │\n│   toasts any response with status >= 500, then rethrows              │\n├─────────────────────────────────────────────────────────────────────┤\n│ AuthInterceptor                                                      │\n│   401 with a token → dispatch Logout with a message, then rethrow    │\n├─────────────────────────────────────────────────────────────────────┤\n│ Service-level catchError                                             │\n│   toast + rethrow, or map to an _ERROR store action                  │\n├─────────────────────────────────────────────────────────────────────┤\n│ ErrorParserService → ErrorVisualizationService                        │\n│   turn an error object into a message, show it as a toast            │\n├─────────────────────────────────────────────────────────────────────┤\n│ Logger / ErrorLogger                                                 │\n│   dev-only console output; Segment events with a redacted store dump │\n├─────────────────────────────────────────────────────────────────────┤\n│ FetchedRequestsInterceptor                                           │\n│   ring buffer of the last 50 requests in the `errors` state slice    │\n└─────────────────────────────────────────────────────────────────────┘\n----\n\n[IMPORTANT]\n====\nA 5xx response typically produces *two* toasts: one from\n`PendingInterceptorService` (`\"<status>: <message>\"`) and one from whatever the calling code\ndoes with the error. Some services add a third. This is expected behaviour in this\ncodebase, not a bug to chase.\n====\n\n== `AppErrorHandler`\n\n`{source-root}/shared/error-handler.ts`, bound to Angular's `ErrorHandler` token:\n\n[source,typescript]\n----\nhandleError(error: any) {\n  if (isDevMode()) {\n    this.displayConsoleMessage(error, true);   // grouped console.error\n    throw error;                               // rethrow in dev\n  } else {\n    const displayErrors  = this.appConfig.env.displayErrorsInConsole;\n    const showErrorPopup = this.appConfig.env.displayErrorsPopup;\n    this.handleErrorProd(error, displayErrors, showErrorPopup);\n  }\n}\n----\n\nTwo environment switches govern production behaviour:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Effect\n\n| `displayErrorsInConsole`\n| `true` in the committed environments — errors are logged to the console. When false the\n  handler prints `\"Check sentry logs or run in dev mode.\"` instead.\n\n| `displayErrorsPopup`\n| `false` in the committed environments. **It is read but never used** —\n  `handleErrorProd` ignores its `displayErrorPopup` argument entirely.\n|===\n\n[CAUTION]\n====\n`AppErrorHandler` in production *only logs*. It does not report anywhere, and the message\nit prints when console logging is disabled tells the user to \"check sentry logs\" — but no\nSentry SDK is installed and nothing reads `sentryUrl`. Uncaught errors in production are\ntherefore visible only through the `window.onerror` → Segment path in `main.ts`.\n====\n\n== `ErrorParserService`\n\n`shared/services/error-parser/error-parser.service.ts` turns an\n`ErrorInterface` (`{ name, errorObject }`) into a display string. The precedence, in order:\n\n. Empty name *and* empty error object → `'Unknown Error.'`.\n. `errorObject` is a string → `'Error:' + name + ' : ' + errorObject`.\n. `errorObject.error.meta.errors[]` exists → the internal API's multi-error envelope;\n  concatenates every `message` separated by `', '`.\n. `errorObject.error.message` exists → returns *just that message*, without the prefix.\n. `errorObject.message` exists → `'Error:' + name + ' : ' + message`.\n. Otherwise → `'Error:' + name + ' : Error unrecognized.'`.\n\nConstants live in `StandardErrorMessages`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Constant | Value\n\n| `UNKNOWN_ERROR` | `'Unknown Error.'`\n| `ERROR_NOT_RECOGNIZED` | `'Error unrecognized.'`\n| `ERROR_PREFIX` | `'Error:'`\n| `SECTION_SEPARATOR` | `' : '`\n| `SEPARATOR` | `', '`\n|===\n\n`GENERAL_ERROR_MESSAGE` (`shared/consts/general-error-message.ts`) —\n`'Sorry, something went wrong...'` — is the user-facing fallback used by components that do\nnot parse.\n\n[NOTE]\n====\nBranch 4 returns the bare message with no `Error:` prefix, while branches 2, 5 and 6 add\nit. Toast wording is therefore inconsistent depending on the error's shape. `isEmpty`\ntreats `{}` and `[]` (by `JSON.stringify` comparison) as empty.\n====\n\nThe multi-error envelope this understands is the internal API's:\n\n[source,json]\n----\n{ \"error\": { \"meta\": { \"errors\": [ { \"message\": \"…\" }, { \"message\": \"…\" } ] } } }\n----\n\nSee xref:architecture:data-modeling.adoc[data modelling] for the other response\nenvelopes.\n\n== `ErrorVisualizationService`\n\nThe single presentation path:\n\n[source,typescript]\n----\nreadonly ERROR_SHOW_TIMEOUT = 9000;\n\nshowError(errorData: ErrorInterface): void {\n  this.toastrService.error(this.errorParserService.parse(errorData), null,\n                           { timeOut: this.ERROR_SHOW_TIMEOUT });\n}\n----\n\nErrors show for 9 seconds. Both this service and `ErrorParserService` have `.mock.service`\nsiblings, and `shared/services/toastr-mock/toastr.mock.service.ts` stubs Toastr for specs.\n\n[TIP]\n====\nFor a user-facing error, inject `ErrorVisualizationService` and call `showError({ name,\nerrorObject })`. Do not call `ToastrService.error` directly — that bypasses the parser and\nproduces inconsistent wording.\n====\n\n== Service-level patterns\n\nThree coexist, oldest first:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Pattern | Where\n\n| `catchError(err => ErrorObservable.create(err))`\n| Older services (`SupplierService`, `LookupRepository`, `ReportsRepositoryService`). A\n  legacy deep RxJS import equivalent to `throwError`.\n\n| `catchError(err => observableThrowError(err))` preceded by a toast\n| Repository services. `PaymentBatchRepositoryService` is the extreme case — eight\n  distinct toast messages (\"Cannot fetch Payment Batches\", \"Cannot store Payment Batch\",\n  …), each followed by a rethrow. See xref:domains:processes.adoc[processes].\n\n| `catchError(...)` returning an `_ERROR` action\n| Store-backed flows. The component reads the error out of state\n  (`supplierError`, `toggleHideError`, `addOptInError`, …).\n|===\n\n`CardManagementService` uses the modern `throwError(() => err)` factory plus a\n`console.error` with structured context — the pattern to copy for new services.\n\n== Logging\n\n=== `Logger`\n\n`{source-root}/shared/logger.ts` — static, dev-only:\n\n[source,typescript]\n----\nLogger.log(msg, ...params)     // grouped, collapsed: '------- SILENT LOG DEBUG --------'\nLogger.error(msg, ...params)   // grouped:            '!!!!!!! SILENT LOG ERROR !!!!!!!'\nLogger.ex(exception, ...params) // same as error\n----\n\nAll three are gated on `isDevMode()`, so they are silent in production builds. Used\nthroughout `AuthEffects`, `SupplierEffects` and `SseService`.\n\nIt is declared `@Injectable()` with an `AppConfigService` constructor parameter but every\nmethod is `static`, so the injection is never used. It is imported both as\n`Logger` (named) and as the default export.\n\n=== `ErrorLogger`\n\n`shared/services/error-logger/error-logger.service.ts` sends structured events to Segment\n*with a redacted store dump*:\n\n[source,typescript]\n----\nlog(title: string, data: any, withStoreDump = true): void {\n  if (withStoreDump) {\n    this.store$.pipe(take(1)).subscribe((dump) => {\n      const store = this.prepareDump(dump);\n      this.fullstoryEvent(title, { data });\n      this.fullstoryEvent(title, { store });\n    });\n  } else {\n    this.fullstoryEvent(title, { data });\n  }\n}\n\nprivate prepareDump(storeData: any): any {\n  const data = { ...storeData };\n  delete data['auth'];               // ← token and user\n  delete data['backgroundReports'];\n  delete data['calc'];\n  delete data['crm'];\n  delete data['layout'];\n  delete data['utils'];\n  return data;\n}\n\nprivate fullstoryEvent(title, data) {\n  try { (<any>window).analytics.track(title, data); } catch (err) {}\n}\n----\n\n[WARNING]\n====\n`prepareDump` removes six slices, so what *remains* is `globalSettings`, `supplier`,\n`marketplace`, `onboarding`, `errors` and `underwriting`. The `supplier` slice contains\nsupplier PII (tax id, owner SSN, addresses, `ownerSSN`), and the `errors` slice contains\nthe last 50 serialised HTTP requests. Both are sent to Segment.\n\nBefore adding an `ErrorLogger.log(..., withStoreDump = true)` call, consider passing\n`false`.\n====\n\nThe method is named `fullstoryEvent` but calls `window.analytics.track` — Segment, not\nFullStory. Historical naming.\n\n=== Request ring buffer\n\n`FetchedRequestsInterceptor` dispatches `FetchRequest(req)` for every request; the\n`errors` reducer keeps the last 50, `JSON.stringify`-ed, in state\n(`REQUESTS_AMOUNT = 50`).\n\n[source,typescript]\n----\nfunction cachedRequestArrayHandler(array, incomingRequest) {\n  let newArr = array;\n  if (array.length === REQUESTS_AMOUNT) { newArr = array.slice(0, -1); }\n  newArr.unshift(JSON.stringify(incomingRequest, null, 1));\n  return newArr;\n}\n----\n\nNote `newArr` is the *same array* when under the cap, so `unshift` mutates the existing\nstate array. It is a debugging aid, visible in Redux DevTools and included in\n`ErrorLogger`'s store dump.\n\n== The error page\n\n`shared/error-handling/tetris/` — a playable Tetris implementation\n(`game.service.ts`, `constants.ts`, `board/`, `piece/`, with specs) shown on the error\npage. `shared/page-not-found/` handles 404s, and `FeatureFlagGuard` navigates to `/404`\nwhen a flag is off.\n\n== Toast conventions\n\nToasts are the *only* notification channel — errors never use dialogs.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Source | Wording\n\n| `ErrorVisualizationService`\n| Parsed message, 9 s.\n\n| `PendingInterceptorService`\n| `\"<status>: <message>\"` for any 5xx.\n\n| `SubPermissionsGuard`\n| `'Access denied.'`\n\n| `SelectorFormComponent`\n| `'Elasticsearch is disabled. No events will be shown.'` (info)\n\n| `LoginComponent`\n| `'Invalid login or password'` (401), `'Access denied'` (403),\n  `'Cannot connect with server'` (other), 3 s; plus any `?message=` query parameter as\n  info for 10 s.\n\n| Repository services\n| Feature-specific \"Cannot …\" messages.\n|===\n\n== Store error state\n\nErrors that must survive a render are kept in state rather than toasted:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Slice field | Set by\n\n| `auth.authError`\n| `LOGIN_FAILED`. `LoginComponent` maps the status to a message and then dispatches\n  `ClearAuthState`.\n\n| `supplier.supplierError`\n| `FETCH_SUPPLIER_DETAILS_ERROR`.\n\n| `supplier.toggleHideError`, `toggleSuspendError`, `addOptInError`,\n  `deleteOptInError`\n| The corresponding `_ERROR` actions.\n\n| `supplier.supplierModelPartErrors[formGroupKey]`\n| `UPDATE_MODEL_PART_ERROR`. Cleared explicitly with `UPDATE_MODEL_PART_MAPS`. See\n  xref:domains:suppliers.adoc#partial-editing[partial editing].\n\n| `supplier.paymentConfigsPending[actionId]`\n| `PC_RESPONSE_ERROR`, cleared with `PC_CLEAR_STATUS`.\n\n| `crm.crmPendingItems[actionId]`\n| `CRM_ITEM_RESPONSE_ERROR`, cleared with `CRM_ITEM_CLEAR_STATUS`.\n|===\n\nThe pattern is consistent: an error written into state must be *cleared explicitly* by the\ncomponent that displayed it.\n\n== Guidance\n\n* User-facing failure → `ErrorVisualizationService.showError({ name, errorObject })`.\n* Failure that must persist across renders → an `_ERROR` action, and remember to clear\n  it.\n* Diagnostic detail → `Logger.log` / `Logger.error` (dev-only).\n* Never `catchError` into a silent `of(null)` for anything the user initiated.\n* Do not add a second toast for an error the interceptor already toasts.\n* Do not send store dumps to analytics without checking what the redaction leaves behind.\n\n== Gotchas summary\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Duplicate toasts on 5xx\n| Interceptor plus caller.\n\n| `displayErrorsPopup` is dead\n| Read from config, never acted on.\n\n| Sentry is not wired up\n| Configured everywhere, used nowhere.\n\n| Parser prefix is inconsistent\n| One branch omits `Error:`.\n\n| `ErrorLogger` sends supplier PII\n| Redaction keeps `supplier` and `errors`.\n\n| `Logger` is static despite `@Injectable`\n| Constructor injection unused.\n\n| Ring buffer mutates state\n| `unshift` on the existing array when under the cap.\n|===\n"},{"component":"internal-admin","module":"platform","path":"platform:caching-and-persistence.adoc","title":"Caching and browser persistence","description":"Every storage key the application writes, what clears it, and the in-memory caches that are easy to miss.","url":"https://docx.payability.com/internal-admin/platform/caching-and-persistence.html","navOrder":41,"source":"= Caching and browser persistence\n:description: Every storage key the application writes, what clears it, and the in-memory caches that are easy to miss.\n\n== Why this page exists\n\nState survives page reloads in six different places, written by reducers, services and\ninterceptors. \"Why does it still show the old value\" and \"why did my override disappear\"\nare almost always answered here.\n\n== `localStorage`\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Key | Written by | Contents and lifetime\n\n| `currentUser`\n| `authReducers` (`setPersistedState` on `LOGIN`); also read directly by\n  `EvaComponent` and `UsersService`\n| The merged user record *including the JWT*. Its presence is what makes the app consider\n  you logged in — `isAuthenticated` is derived from it at reducer-init time. Cleared by\n  `localStorage.clear()` on logout.\n\n| `suppliers_last_query`\n| `supplierReducers` (`setPersistedQuery` on `SET_SUPPLIERS_QUERY_STRING`)\n| The last supplier-list query string. When absent the default is\n  `created_since:<today − 1 day>`, which is why a fresh session shows only recently\n  created suppliers.\n\n| `layoutState`\n| `layoutReducers`\n| Persisted layout preferences — CRM/supplier-info panel states per route pattern.\n\n| `lscache-utilsState` (plus an `lscache` expiry key)\n| `utilsReducers` via `lscache.set(UTILS_STATE_KEY, …, 24 * 60)`\n| Reference data — timezones, funding periods, US states, countries. **24-hour TTL.**\n\n| `ng-cache-<md5>`\n| `HttpCacheLocalStorage`\n| Cached `GET` responses, keyed by `MD5(JSON.stringify(request))`, valid for **5\n  minutes**. Only written if something invokes the cache — `CachingInterceptor` is *not*\n  registered, so in practice this is dormant. See below.\n|===\n\n[WARNING]\n====\n`AuthEffects.clearStorages()` is `localStorage.clear()` — it wipes *everything*, not just\nthe session. Logging out therefore also discards the persisted supplier query, the layout\npreferences and the reference-data cache. That is why the first page after a login is\nslower and the supplier list resets to the default query.\n====\n\n== `sessionStorage`\n\nPer tab, so two tabs can legitimately disagree.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Key | Written by | Contents\n\n| `loginRedirectUrl`\n| `RouteAccessResolver.handleLogout()`\n| The URL to return to after login, with `/#/` stripped. Read by\n  `AuthEffects.login`. **Never cleared after use** — see\n  xref:authentication.adoc[authentication].\n\n| `FeatureExecFlags`\n| `FeatureFlagsService.storeAvailableFlagsExecStatus`\n| The resolved flag set for this application as `[{ FLAG: boolean }, …]`.\n\n| `FeatureExecFlagsLastChange`\n| Same\n| Timestamp of the last override change. Compared against the global setting's\n  `lastChange` to decide whether session overrides still apply.\n\n| `FeatureExecFlagsEva`\n| `FeatureFlagsEvaService`\n| EVA's resolved flag set — deliberately separate keys so the two applications' overrides\n  never collide.\n\n| `FeatureExecFlagsLastChangeEva`\n| Same\n| EVA's override timestamp.\n|===\n\nFlag keys are cleared by `clearAvailableFlagsExecStatus()`, which `LoginComponent` calls\non entry. See xref:feature-flags.adoc[feature flags].\n\n== Cookies\n\nOne cookie, written through `shared/services/cookie-service/cookie-service.ts` (a thin\nwrapper over `ngx-cookie-service`):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Cookie | Detail\n\n| `PYBEvaToken`\n| The JWT, set by `EvaService.passTokenToEva()` on `environment.domain` (the shared parent\n  domain) with `path: '/'` and `expires: 1` day. Deleted and rewritten on each set.\n  `EvaComponent` clears it on `window:unload` / `beforeunload`.\n  **Not written on `localhost`** — there the token goes in the iframe URL instead. See\n  xref:iframe-messaging.adoc[iframe messaging].\n|===\n\n`CookieService` also exposes `getAllCookies()` and `deleteAllCookies()`; neither is used in\nthe current code.\n\n== The HTTP response cache\n\n`HttpCacheAbstract` → `HttpCacheLocalStorage`, bound in `AppModule`:\n\n[source,typescript]\n----\nprivate readonly localStorageCacheKeyPrefix = 'ng-cache-';\nprivate readonly validCacheTimespan = 5 * 60 * 1000;    // 5 minutes\n\nget(req)  → JSON.parse(localStorage[key]); if older than 5 min, removeItem and return null\nput(req, resp) → localStorage[key] = { data: resp, timestamp: Date.now() }\nkey    → 'ng-cache-' + Md5.hashAsciiStr(JSON.stringify(req))\n----\n\nEvery method is wrapped in `try/catch` returning `null` or doing nothing, so a quota error\nor corrupt entry degrades silently.\n\n`CachingInterceptor` implements read-through caching for `GET`s against this cache — and\nis *not* registered in `AppModule`'s `HTTP_INTERCEPTORS`. It is dormant.\n\n[CAUTION]\n====\nIf you enable `CachingInterceptor`, note that:\n\n* It caches *all* `GET`s, including permission- and supplier-scoped responses, keyed only\n  by the serialised request. Two users on the same machine would share entries.\n* It `cloneDeep`s the request before caching because \"the request is modified internally\n  during next steps\" — i.e. the interceptor chain mutates it.\n* The 5-minute TTL applies uniformly; there is no per-endpoint control.\n* Responses are stored as whole `HttpResponse` objects in `localStorage`, so large payloads\n  can exhaust quota.\n====\n\n== Domain caching in the store\n\nThe application's real caching strategy is *in-state*, not HTTP-level.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Cache | Behaviour\n\n| `supplier.suppliers[supplierKey]`\n| Every supplier visited this session. `SupplierEffects.getSupplierDetails` returns the\n  cached aggregate unless `payload.refresh` is set. A refresh also appends\n  `refreshTimestamp=Date.now()` to the request. **Stale by design** — nothing invalidates\n  it. See xref:domains:suppliers.adoc[suppliers].\n\n| `crm.crmStatesList[targetUuid]`\n| Per-target CRM timeline state, so switching between suppliers does not re-fetch.\n\n| `utils` slice\n| Reference data, additionally persisted with a 24-hour TTL.\n\n| `globalSettings` slice\n| The settings collection; `GlobalSettingsService.getAll(force)` re-fetches only when\n  forced.\n\n| `marketplace.possiblePaymentGateways`\n| The gateway list.\n\n| Effect-level `of()` short-circuits\n| Nearly every fetch effect checks state first and returns an empty observable on a hit.\n|===\n\n[IMPORTANT]\n====\nSeveral supplier reducer cases *reset* the slice, which silently drops these caches:\n\n* `FETCH_SUPPLIER_DETAILS` returns `{ ...initialState, suppliers, lastQueryString }` —\n  clearing the part-edit maps and all progress flags.\n* `ADD_PAYMENT_GATEWAY` returns `{ ...initialState, supplierModel }` — discarding the\n  whole `suppliers` cache *and* `lastQueryString`.\n* `UNLOAD_SUPPLIER` keeps `suppliers` and `lastQueryString` and resets the rest.\n====\n\n== In-memory service caches\n\nEasy to miss because they are neither store nor storage.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Cache | Behaviour and hazard\n\n| `UsersService.usersSubject` / `groupsSubject` / `usersAndGroupsSubject`\n| `BehaviorSubject`s. `getPreparedCombinedList()` fetches users and groups once and caches\n  for the session — with *no invalidation*. A user created in this session will not appear\n  in an assignee picker until reload.\n\n| `UsersService.subPermsArr`\n| Caches `perms/:permCode/sub-perms` — **ignoring `permCode`**. A second call for a\n  different permission returns the first one's results. Only one caller exists today. See\n  xref:authorization.adoc[authorisation].\n\n| `FeatureFlagsService.globalSettingsFetchedFlags`\n| `BehaviorSubject` keyed by setting key; cleared by\n  `clearGlobalSettingsFetchedFlags()`.\n\n| `FeatureFlagsService.lastUpdateFFfromGlobalSettings`\n| A `Record<string, number>` of `lastChange` timestamps per setting key.\n\n| `PendingOfferRiskRatingService.defaultProgressiveAdvanceSettings`\n| Memoises the pricing-defaults fixture. See xref:domains:advances.adoc[advances].\n\n| `B2bWorkflowService.streamShared$`\n| The shared SSE stream — never reset, so a dead stream is never replaced. See\n  xref:realtime.adoc[real-time streams].\n\n| `LookupRepository` / `ConfigsService`\n| Backed by the `utils` and `globalSettings` slices respectively rather than their own\n  caches.\n|===\n\n== What clears what\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Action | Clears\n\n| Logout (`LOGOUT` or `LOGIN_FAILED`)\n| *All* of `localStorage` — session, supplier query, layout, `lscache` reference data.\n  Also `EvaComponent` clears `PYBEvaToken` on unload.\n\n| Visiting `/login`\n| `FeatureExecFlags*` (both applications) via\n  `clearAvailableFlagsExecStatus()` and `FeatureFlagsEvaService.reset()`.\n\n| Editing flags in DEV Admin → save to global settings\n| Effectively invalidates every user's session overrides, because the new `lastChange` is\n  newer than their stored timestamp.\n\n| Page reload\n| All in-memory service caches and the whole store, except what is rehydrated from storage\n  (`auth`, `utils`, `supplier.lastQueryString`, `layout`).\n\n| New tab\n| `sessionStorage` starts empty — flags re-resolve from build + global settings.\n\n| 24 hours\n| `lscache` reference data expires.\n\n| 5 minutes\n| `ng-cache-*` entries expire (when the cache is used at all).\n|===\n\n== Security notes\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Item | Consideration\n\n| JWT in `localStorage`\n| Readable by any script in the page. Accepted for an internal tool on a controlled\n  network; do not extend the pattern.\n\n| JWT in a cookie on the parent domain\n| Necessary for the EVA seam. `secure` / `sameSite` are supported by `CookieService` but\n  not passed by `EvaService`.\n\n| JWT in the iframe URL on localhost\n| Appears in browser history. Local development only.\n\n| Request ring buffer in state\n| The `errors` slice holds the last 50 serialised requests, including bodies, and is\n  visible in Redux DevTools — which is instrumented in production. It is also included in\n  `ErrorLogger`'s store dump. See xref:error-handling.adoc[error handling].\n\n| Supplier PII in state\n| `supplier.suppliers` holds tax ids, owner SSNs and addresses for every supplier visited\n  this session.\n|===\n\n== Practical guidance\n\n* Reading persisted state: go through the store, not `localStorage`, except where existing\n  code already does otherwise (`EvaComponent`, `UsersService`).\n* Adding a storage key: define the key as a `const` next to its writer, and add it to this\n  page.\n* Needing fresh domain data: dispatch the fetch action with\n  `refresh: true` rather than clearing storage.\n* Debugging \"stale data\": check the store cache first\n  (`supplier.suppliers[key]`), then the service-level `BehaviorSubject`s, then storage.\n* Do not rely on `localStorage.clear()` semantics — if you add a key that must survive\n  logout, logout will delete it.\n"},{"component":"internal-admin","module":"platform","path":"platform:observability.adoc","title":"Observability and analytics","description":"What the application reports about itself — Segment, Google Analytics, the KYC guardrail audit trail, version reporting and the VPN check.","url":"https://docx.payability.com/internal-admin/platform/observability.html","navOrder":42,"source":"= Observability and analytics\n:description: What the application reports about itself — Segment, Google Analytics, the KYC guardrail audit trail, version reporting and the VPN check.\n\n== What exists, and what does not\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Channel | Status | Detail\n\n| Segment (`analytics.js`)\n| Live\n| `identify` on login, `track` for events and errors. Key\n  `env.frontendSegmentIOCode`.\n\n| Google Analytics (classic)\n| Live\n| Snippet injected under the global name `fga`. No page/event calls in application\n  code — the snippet's own auto-tracking only.\n\n| Event logger (`track`)\n| Live, narrow\n| One purpose: the KYC secure-site guardrail trail. See\n  xref:#guardrail[the guardrail trail].\n\n| Redux DevTools\n| Live in *all* builds\n| `StoreDevtoolsModule.instrument()` is unconditional.\n\n| Request ring buffer\n| Live\n| Last 50 requests in the `errors` state slice.\n\n| Version reporting\n| Live\n| Front-end and back-end versions in the sidebar and on the login screen.\n\n| VPN check\n| Flag-gated\n| `CHECK_VPN`.\n\n| Sentry\n| **Not wired up**\n| `sentryUrl` exists in every environment and is identical across templates. No SDK\n  is installed and no code reads it. `AppErrorHandler` nonetheless tells users to\n  \"check sentry logs\".\n|===\n\n[IMPORTANT]\n====\nThere is no error-tracking service. Production errors reach only the browser console\nand, if uncaught, a Segment `WINDOW ERROR` event. Do not assume a stack trace exists\nsomewhere. See xref:error-handling.adoc[error handling].\n====\n\n== `MonitorService`\n\n`{source-root}/shared/monitor.service.ts` is the analytics entry point. It does four\nthings.\n\n=== 1. Bootstraps Segment\n\nIt inlines the standard Segment snippet by hand — building the `analytics` stub\narray, the method list, the factory, and finally:\n\n[source,typescript]\n----\nanalytics.load(this.segmentCode);        // env.frontendSegmentIOCode\nanalytics.page();\n----\n\nThe script URL is\n`https://cdn.segment.com/analytics.js/v1/<code>analytics.min.js`. Note the write key\nin the environment files ends with a `/` (`'…zxA3/'`), and `set-env.ts` appends\nanother `/` — the concatenation is deliberate.\n\n=== 2. Bootstraps Google Analytics\n\nThe classic GA snippet, assigned to the global `fga` rather than `ga`. Nothing in the\napplication calls it; it is present for whatever GA's own auto-collection provides.\n\n=== 3. Identifies the user\n\n[source,typescript]\n----\ninitFullStory(): void {\n  this.store.select(selectLoggedUserModel)\n    .pipe(filter((u) => !!u))\n    .subscribe((loggedUser: UserModel) => {\n      window.analytics.identify(loggedUser.name, {\n        email: loggedUser.email,\n        displayName: loggedUser.name,\n      });\n    });\n}\n----\n\nThe method is named for FullStory but calls Segment. Note the *identity* passed is\n`loggedUser.name` (a display name) rather than a stable id such as `loginKey` — so\nSegment identities are not stable across a name change.\n\n=== 4. Maintains a message queue\n\n`send(message)` → `processQueue()` → `processMessage()` → `sendSegmentEvent()`. The\nqueue and `serviceBusy` flag exist for a series-of-actions design that was never\nfinished — the commented-out `EVENTS[message.subject].actions` block with an\n`Async.eachLimit` loop is still in the source, attributed in a comment.\n\n`sendSegmentEvent` flattens nested data into dash-joined keys\n(`flatten(source, dest, '-', path)`) and attaches screen dimensions as context:\n\n[source,typescript]\n----\nwindow.analytics.track(message.subject, this.flatten(onboardingData), {\n  context: { screen: { width: screen.width, height: screen.height } },\n});\n----\n\n[[guardrail]]\n== The KYC guardrail audit trail\n\nThe one genuinely load-bearing piece of telemetry. `MonitorService.send()` only logs\nfor users holding a *restricted* permission:\n\n[source,typescript]\n----\npublic send(message?): void {\n  if (![USER_PERMISSION.REST_UW_ACCESS, USER_PERMISSION.ADVANCE_AUDITORS]\n        .includes(this.userPerrmision as USER_PERMISSION) || this.tracked) {\n    return;\n  }\n  this.logEvent(message);\n  this.tracked = !this.tracked;\n}\n----\n\n`logEvent` sends both a Segment `identify` and an HTTP `POST` to\n`$\\{eventLoggerUrl}track` with:\n\n[source]\n----\nbody:    { user, url: 'test`', target_type: 'supplier',\n           subject: 'Restricted UW Access',\n           extra: { route, eventTypeCode: 'kyc_secure_site_guardrails', suppKey? } }\nheaders: Authorization: 'Token 79e4933035c840438f999f8ad9e38571'\n         X-EventLogger-InstanceId: '8003'\n         X-EventLogger-Type: 'analitics'\n         X-EventLogger-TargetUUID: <suppKey>       (when present)\n----\n\nSo access to a supplier by a `REST_UW_ACCESS` or `ADVANCE_AUDITORS` user is recorded\nas a `kyc_secure_site_guardrails` event against that supplier. That is the compliance\npurpose of those two permissions' logging behaviour — see\nxref:authorization.adoc[authorisation].\n\n[WARNING]\n====\nSeveral problems are visible in this method, all known debt:\n\n* `Authorization: Token <literal>` — a **hardcoded credential** in source. The only\n  one in the codebase.\n* `X-EventLogger-InstanceId: '8003'` is hardcoded, while every other consumer uses\n  `env.instanceId` (8001).\n* `data.url` is the literal string `` 'test`' `` — a placeholder with a stray\n  backtick. The real route is in `extra.route`.\n* `this.userPerrmision` (sic) is set from `data.user.perms[0].permCode` — **only the\n  first permission**. A restricted user whose restricted code is not first is not\n  logged.\n* `tracked` is a one-shot toggle, so at most one event is logged until\n  `setUntracked()` is called.\n* The `POST` subscribes with two empty handlers, so a failure to record the audit\n  event is silently swallowed.\n* `X-EventLogger-Type: 'analitics'` is misspelled — and matches the misspelling in\n  `eventTypes`. Keep it.\n\nTreat the guardrail trail as best-effort, not as a reliable audit log.\n====\n\n== `window.onerror`\n\n`src/main.ts` installs a last-resort handler outside Angular:\n\n[source,typescript]\n----\nwindow.onerror = function (msg, url, lineNo, columnNo, error) {\n  try {\n    window.analytics.track('WINDOW ERROR', { msg, url, lineNo, columnNo, error });\n  } catch (err) {}\n  return false;\n};\n----\n\nReturning `false` lets the default handler also run. The `try/catch` means a missing\n`analytics` object cannot break it. This is the only automatic error reporting in\nproduction.\n\n== `ErrorLogger`\n\n`shared/services/error-logger/error-logger.service.ts` sends a Segment event with an\noptional **store dump**, redacting six slices (`auth`, `backgroundReports`, `calc`,\n`crm`, `layout`, `utils`).\n\nWhat survives redaction includes `supplier` — which holds tax ids, owner SSNs and\naddresses for every supplier visited — and `errors`, which holds the last 50\nserialised requests.\n\n[CAUTION]\n====\nPass `withStoreDump = false` unless you have a specific reason. See\nxref:error-handling.adoc[error handling] for the full analysis.\n====\n\n[[versions]]\n== Version reporting\n\n`shared/version.service.ts`:\n\n[source,typescript]\n----\ngetFrontendVersion(): string          // environment.VERSION — from package.json at build\ngetBackendVersion(): Observable<string> // GET <internalApiUrl>version, shareReplay(1)\n----\n\nSurfaced in two places:\n\n* The sidebar footer — `F: <feVersion>` and `B: <beVersion>`\n  (`data-test=\"sidebarFeVersion\"` / `sidebarBeVersion`).\n* The login screen, before authentication.\n\n`semver` is a dependency for comparing the two, and `environment` also carries\n`gitHash` (the full 40-character commit) and `lastBuildDate` (ISO to the minute),\nboth stamped by `deployment/set-env.ts`. See\nxref:ops:environments.adoc[environments].\n\n=== The stage banner\n\n`shared/layout/app-stage-info/` is designed to show which environment and backend you\nare pointed at, including a truncated git hash (`HASH_LENGTH_TO_SHOW = 8`) and the\nbuild date, and only on `envName === 'test'`.\n\n[NOTE]\n====\nIt is currently **disabled**: `getStageName()` returns `of('')` with the real\nimplementation commented out. The helper methods (`getAppBaseUrl`,\n`getConnectedBackend`, `getGitHash`, `getBuildDate`,\n`removeConstantPartsFromUrl`) are all still there. Re-enabling it is a one-line\nchange — useful when you need to know which backend a deployed environment is talking\nto.\n\nThe component uses `ViewEncapsulation.None`, so its styles are global.\n====\n\n== The VPN check\n\n`shared/vpn-tester.service.ts`, started by `AppComponent` when `CHECK_VPN` is\nenabled:\n\n[source,typescript]\n----\nprivate readonly DELAY = 3000;      // first check after 3 s\nprivate readonly INTERVAL = 30000;  // then every 30 s\n\ntimer(DELAY, INTERVAL).pipe(\n  mergeMap(() => this.http.options(`${environment.pybDocumentsApiUrl}health-check`)\n    .pipe(mapTo(true), catchError(() => of(false)))),\n).subscribe((hasVpn) => {\n  if (hasVpn) return;\n  this.toastrService.error(\n    'Please turn on the Payability VPN. Without it you may experience functionality issues');\n});\n----\n\nIt probes an internal host with an `OPTIONS` request every 30 seconds and toasts on\nfailure. Purely advisory — nothing is blocked. The real network restriction is\nenforced upstream.\n\n[CAUTION]\n====\nThe subscription is never torn down, and it toasts on *every* failed check — so an\noperator genuinely off-VPN accumulates a toast every 30 seconds. Also note\n`AppComponent` calls `initVpnTester()` from its *constructor*, before flags are\ninitialised by the route guard, so on a cold load the flag check may read `false` and\nthe tester never starts. See xref:feature-flags.adoc[feature flags].\n====\n\n== The `Logger` helper\n\n`{source-root}/shared/logger.ts` — static, `isDevMode()`-gated console output with\ndistinctive group headers (`------- SILENT LOG DEBUG --------`,\n`!!!!!!! SILENT LOG ERROR !!!!!!!`). Silent in production. Used by `AuthEffects`,\n`SupplierEffects` and `SseService`.\n\n== Developer-facing observability\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Tool | Detail\n\n| Redux DevTools\n| Instrumented unconditionally — including production builds. The fastest way to see\n  what the app is doing.\n\n| Request ring buffer\n| Last 50 requests (serialised, bodies included) in `state.errors.cachedRequests`.\n\n| Feature-flag console dump\n| `FeatureFlagsGeneralGuard` prints the resolved flag table once per page load in\n  non-production. See xref:domains:admin.adoc[DEV Admin].\n\n| Iframe message log\n| `CommunicationService` logs every `[IA] Sending` / `[IA] Received` in\n  non-production. See xref:iframe-messaging.adoc[iframe messaging].\n\n| Store debugger\n| `{source-root}/store-debugger.ts` — requires editing\n  `EnableStoreDebugging = false` in source. Prefer DevTools.\n|===\n\n[CAUTION]\n====\nDevTools instrumentation plus the request ring buffer means a production build\nexposes the full store — including supplier PII and recent request bodies — to anyone\nwith the browser extension. Accepted for an internal tool; worth knowing.\n====\n\n== Adding telemetry\n\n* Business event → `MonitorService.send({ subject, data })`. Remember it only fires\n  for restricted users; if you need it for everyone, call\n  `window.analytics.track` through a new method rather than reusing `send`.\n* Diagnostic detail → `Logger.log` (dev-only).\n* Error context → `ErrorLogger.log(title, data, false)`.\n* Never add a hardcoded credential. If a new endpoint needs auth, it goes through\n  `AuthInterceptor` like everything else — see\n  xref:architecture:http-and-services.adoc[HTTP layer].\n"},{"component":"internal-admin","module":"platform","path":"platform:shared-library.adoc","title":"Shared library","description":"Inventory of `src/app/shared` — what is there, which generation is current, and what to reach for.","url":"https://docx.payability.com/internal-admin/platform/shared-library.html","navOrder":43,"source":"= Shared library\n:description: Inventory of `src/app/shared` — what is there, which generation is current, and what to reach for.\n\n== What it is\n\n`{source-root}/shared/` is the application-wide library: ~67 components, 18\ndirectives, 12 pipes, plus services, interceptors, guards, resolvers, models and\nutilities. It is imported by every feature module through `SharedModule`.\n\nThe rule for placement: application-wide code lives here; module-wide code lives in\nthat module's own `shared/`; component-specific code lives with the component. See\nxref:architecture:module-map.adoc[the module map].\n\n[IMPORTANT]\n====\nSeveral concerns exist in more than one generation. This page names the current one\nfor each. Before adding a shared component, check whether one of the older\ngenerations already covers it — and if it does, extend the *current* one rather than\nadding a fifth option.\n====\n\n== `SharedModule` exports\n\nEverything below is exported by `SharedModule` and available in any feature module\nthat imports it.\n\n=== Tables and grids\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Component | Status | Use\n\n| `pyb-ag-grid/`\n| **Current**\n| The AG Grid wrapper with the project's defaults. Use this for new grids.\n\n| `DataTableAgGridComponent`\n| Widely used\n| AG Grid table with header-template rendering\n  (`data-table-ag-grid-header-template-renderer.service.ts`) and summed-value headers\n  (`services/headers-with-summed-values-setting.service.ts`).\n\n| `DataTableGridComponent`\n| Transitional\n| Includes `ag-greed-data-reshaper.service.ts` (sic) reshaping API responses into grid\n  rows.\n\n| `data-table/`\n| Oldest\n| Material-table based. Its `data-table.common.ts` still supplies\n  `DataTableQueryParams` — the shared query-parameter contract used by services such\n  as `BalanceApprovalService`, so the folder cannot simply be deleted.\n\n| `DataGridComponent` + `DataGridFormatPipe`, `DataGridOrderByPipe`\n| Light\n| Small non-AG-Grid grid.\n\n| `SimpleTableComponent`\n| Light\n| Fixed short lists.\n\n| `cell-components/`\n| Shared\n| AG Grid cell renderers used across *all* generations —\n  e.g. `ActiveContractsStatusCellComponent`. Grep before changing one.\n\n| `PybPaginationComponent`\n| —\n| Pagination for the non-AG-Grid tables.\n|===\n\nAG Grid **enterprise** features are in use; the licence key is set in `main.ts` with\nan expired `ExpiryDate`, which produces a console warning but does not disable\nfunctionality. See xref:architecture:tech-stack.adoc[technology stack].\n\n=== Dialogs\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Component | Use\n\n| `dialog/` (`DialogModule`, `DialogService`, `dialog.config.ts`)\n| The dialog opener and shared `MatDialogConfig` defaults. Use `DialogService`, not\n  `MatDialog` directly.\n\n| `ConfirmDialogComponent`\n| Generic yes/no. `data` takes `title`, `message`, an optional `alertMessage` rendered\n  above it in a red alert box, and `isProceedButtonVisible` to swap Yes/No for a\n  Proceed/Close pair whose confirm button `proceedLabel` can rename.\n\n| `ConfirmDeleteComponent`\n| Delete confirmation with the item name.\n\n| `InformationDialogComponent`\n| Informational, single button.\n\n| `ReasonDialogComponent`\n| Confirmation that *requires* a free-text reason — used where the reason is\n  persisted (supplier lifecycle actions). Mode enum in\n  `shared/enums/reason-dialog-mode.enum.ts`.\n\n| `SummaryDialogComponent`\n| Shows a computed summary before committing.\n\n| `B2bPreviewDialogComponent`\n| Inline document preview.\n\n| `EvaConfirmDialogComponent`\n| Confirmation raised on behalf of an embedded EVA frame.\n|===\n\nDomain-specific modals live with their feature (the ~20 folders under\n`suppliers/supplier/`), not here.\n\n=== Forms and inputs\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Component | Use\n\n| `CardFormgroupComponent` + `card-formgroup-message.service.ts`\n| The editable-card pattern: titled panel with edit/save/cancel submitting one\n  `formGroupKey`. This is the UI half of\n  xref:domains:suppliers.adoc#partial-editing[partial editing] — the most important\n  shared component in the app.\n\n| `DynamicFormComponent`, `DynamicFormFieldComponent`,\n  `forms/dynamic-form-control.service.ts`\n| Configuration-driven forms, where the field set comes from data (report\n  configuration, onboarding templates).\n\n| `inline-edit/` — `InlineEditTextComponent`, `…TextareaComponent`,\n  `…SelectComponent`, `…DateComponent`, `…DateAndTimeComponent`,\n  `…NumberSliderComponent`, `…SwitchComponent`, `…YesNoComponent`,\n  `InlineAddTextComponent`, `InlineNumberComponent`\n| A full family of in-place editors. Pick the specific one; do not build another.\n\n| `InputFileComponent`\n| File selection for uploads.\n\n| `RadiobuttonsComponent`, `CheckboxFiltersComponent`, `ChipComponent`,\n  `TimeSelectComponent`\n| Input primitives.\n\n| `editor/` (`EditorModule`)\n| TinyMCE wrapper for email bodies and rich notes.\n\n| `DragAndDropListComponent`\n| Ordering UI.\n\n| `@ultimate/ngxerrors`\n| Declarative validation messages. Legacy; used in older forms.\n|===\n\n=== Display primitives\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Component | Use\n\n| `TextValueComponent`\n| Read-only label/value pair.\n\n| `TitleWithIconComponent`\n| Section header with an icon.\n\n| `NoDataComponent`\n| Empty state.\n\n| `SpinnerButtonComponent`\n| Button with an inline progress state.\n\n| `loading-spinner/`, `http-loader/` (`SpinnerComponent`, `PreloaderDirective`)\n| The global loading indicators, driven by `PendingInterceptorService`.\n\n| `TaskCountBadgeComponent`\n| The sidebar task counters. `filterType` of `currentUser` or `currentUserGroup`. See\n  xref:domains:crm.adoc[CRM].\n\n| `NotyfToastComponent`\n| A toast variant.\n\n| `ExternalReportComponent`\n| Renders an external report feed. See xref:domains:reports.adoc[reports].\n|===\n\n[[search]]\n=== Filtering and search\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Component | Use\n\n| `FilteringPanelComponent` +\n  `services/filtering-panel-service/filtering-panel.service.ts`\n| Composite filter panel coordinating multiple controls.\n\n| `FilterControlInputComponent`, `FilterControlSelectComponent`,\n  `FilterControlDatepickerComponent`\n| The individual filter controls.\n\n| `SearchOverlayComponent`, `SearchOverlaySingleResultComponent`,\n  `SearchOverlayDirective`, `OpenSearchOverlayDirective`\n| The global search modal, mounted once in the shell and opened by the sidebar.\n\n| `search-overlay/search-engine-service-3-0/`\n| `SearchEngineService` (the internal API's `search3` parameter, with a legacy\n  `search2` path still present), `ElasticHighlightsService`, `SearchDataModel`.\n|===\n\n`SearchEngineService.pybEncode` percent-escapes `?`, `=`, `&`, `+` as\n`~F`, `~3D`, `~26`, `~2B` before sending the phrase — a backend-specific encoding, not\nURL encoding. `search()` has its feature check short-circuited to `if (1 === 1)`.\n\n=== Layout and navigation\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Component/directive | Use\n\n| `layout/` — `MainLayoutComponent`, `AppStageInfoComponent`,\n  `FabHolderNavComponent`, `LayoutService`, `layout.enums.ts`, `store/`\n| The shell. See xref:architecture:ui-composition.adoc[UI composition].\n\n| `sidenav/` — `AppSidenavComponent`, `AppSidenavMenuComponent`\n| The sidebar and its hand-maintained menu tree.\n\n| `AccordionNavDirective`, `AppendSubmenuIconDirective`,\n  `HighlightActiveItemsDirective`, `AutoCloseMobileNavDirective`,\n  `ToggleOffcanvasNavDirective`\n| Sidebar behaviour.\n\n| `StickyDirective`\n| Sticky positioning.\n\n| `DisabledReasonDirective`\n| Shows *why* a control is disabled — the tooltip pattern used across the supplier\n  workspace where a field is read-only due to status or permission.\n\n| `AppFooterComponent`, `PageNotFoundComponent`\n| Footer and 404.\n\n| `animations/`\n| Shared animation triggers (panel slide, expand/collapse).\n|===\n\n=== Pipes\n\n`CapitalizePipe`, `CurrencyPipe`, `DatePipe`, `PercentPipe`, `EllipsisPipe`,\n`FromUnixPipe`, `KeysPipe`, `SafeHtmlPipe`, `SafeValuePipe`, `FormatPreviewPipe`,\nplus the two data-grid pipes.\n\n[CAUTION]\n====\n`CurrencyPipe`, `DatePipe` and `PercentPipe` here *shadow* Angular's built-ins of the\nsame name. Which one a template resolves depends on module import order. When\ndebugging unexpected formatting, check whether the shared pipe is in play.\n====\n\n=== Documents\n\nThree document backends, three wrappers:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Folder | Backend\n\n| `apps-document/`\n| `AppsDocumentService`, `AppsDocumentDownloadDirective`.\n\n| `b2b-document/`\n| `b2bDocumentsApiUrl` (+ mock).\n\n| `pyb-documents/`\n| `pybDocumentsApiUrl`, behind the `PYB_DOCUMENTS` flag.\n|===\n\nA fourth path exists — `s3FilesProviderApi` — and deposit-account documents use\n`documentsApiUrl`. Which store a given attachment is in is not always inferable from\nthe front end.\n\n=== EVA and iframes\n\n`eva/` (`EvaComponent`, `EvaService`, `EvaCommunicationService`, selector and cookie\nconstants, `EvaPermissions`) and `iframe/` (`CommunicationService`,\n`ResizeIframeService`, `IframeComponent`, `MESSAGE_TYPE`). See\nxref:iframe-messaging.adoc[iframe messaging and EVA].\n\n=== The error-page game\n\n`error-handling/tetris/` — `TetrisComponent`, `BoardComponent`,\n`PybtrisButtonsComponent`, `piece/`, `game.service.ts`, `constants.ts`, with specs.\nShown on the error page. See xref:error-handling.adoc[error handling].\n\n== Services in `shared/`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Purpose\n\n| `services/error-parser/`, `error-visualization/`, `error-logger/`\n| The error pipeline. See xref:error-handling.adoc[error handling].\n\n| `services/long-term-contracts/`\n| Contract templates and supplier contracts (+ abstract + mock). See\n  xref:domains:fees-and-reserves.adoc#contracts[contracts].\n\n| `services/cookie-service/`\n| Wrapper over `ngx-cookie-service`. See\n  xref:caching-and-persistence.adoc[caching and persistence].\n\n| `services/toastr-mock/`\n| Toastr stub for specs.\n\n| `lookup-service/`\n| `LookupAbstractService` → `LookupRepository`: timezones, funding periods, states,\n  countries, transaction types, plus `getAuthGoogleLogin`. Backs the `utils` slice.\n\n| `configs/`\n| `ConfigsAbstractService` → `ConfigsService` over global settings, with\n  `ConfigTypes` / `ConfigEmailTypes`. See xref:domains:manage.adoc[manage].\n\n| `users/`\n| `UsersAbstractService` → `UsersService`, plus `UserModel`, `UserPermModel`,\n  `PermissionModel` and `USER_PERMISSION`. See\n  xref:authorization.adoc[authorisation].\n\n| `feature-flags/`\n| `FeatureFlagsService`, `FeatureFlagsAdminService`, `FeatureFlagsEvaService`,\n  `FeatureFlagGuard`, `EnabledByFeatureFlagDirective`. See\n  xref:feature-flags.adoc[feature flags].\n\n| `interceptors/`\n| `FetchedRequestsInterceptor` and its actions/reducers.\n\n| `cache/`\n| `HttpCacheAbstract` → `HttpCacheLocalStorage`, plus the unregistered\n  `CachingInterceptor`.\n\n| `csv-export-service/`\n| `CsvExportService`. Note `dataToString` keeps only string values.\n\n| `sse.service.ts`\n| Server-sent events. See xref:realtime.adoc[real-time streams].\n\n| `table-events.service.ts`\n| The in-app publish/subscribe bus.\n\n| `monitor.service.ts`\n| Segment/GA bootstrap and the KYC guardrail trail. See\n  xref:observability.adoc[observability].\n\n| `version.service.ts`\n| Front-end and back-end versions.\n\n| `vpn-tester.service.ts`\n| The `CHECK_VPN` probe.\n\n| `message.service.ts`\n| Canned message codes (`installExtension`, `cantReceiveToken`,\n  `inTestEnvironmentsCanOnlyTo`) rendered by `/welcome/:messageCode`.\n\n| `dynamic-embedder.service.ts`\n| Dynamic component embedding.\n\n| `logger.ts`\n| Static dev-only console logging.\n\n| `onboarding/`\n| `OnboardingService` for onboarding templates (+ mock).\n\n| `external-report/`\n| The `ExternalReports` enum and rendering.\n|===\n\n== Utilities, models and constants\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Folder | Contents\n\n| `component-subscriber/`\n| `SubscriberComponent` — the base class that collects `this.subscriptions` and\n  unsubscribes on destroy. **Extend this** rather than hand-rolling `takeUntil`.\n\n| `utils/`\n| `route-access-resolver.ts` (the auth guard), the `utils` store slice,\n  `to-lowercase-underscore.util.ts`, `track-by-index.util.ts`,\n  `communication-commands.util.ts`, `model-interface.ts`.\n\n| `functions/`\n| Pure helpers, e.g. `check-for-special-occasion.ts` (seasonal theming).\n\n| `enums/`\n| `custom-sort-direction.ts`, `form-validation-status.enum.ts`,\n  `icon-class.enum.ts`, `marketplace.enum.ts`, `payment-type.ts`,\n  `reason-dialog-mode.enum.ts`, `true-false-filter-amount.enum.ts`.\n\n| `consts/`\n| `supplier-status.enum.ts` (`SUPPLIER_STATUS`), `available-statements.const.ts`,\n  `pyb-products.const.ts`, `uw-tool.enum.ts`, `general-error-message.ts`.\n\n| `models/`\n| `key-value.ts`, `uw-tool-entity.model.ts` and other cross-cutting types.\n\n| `interfaces/`\n| `ServiceUpdatePartialInterface` — `saveItemPart(itemKey, itemPart, ...params)`, the\n  contract that makes partial editing work uniformly.\n\n| `resolvers/`\n| `SupplierKeyResolver` (+ mock).\n|===\n\n== Choosing what to use\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Need | Reach for\n\n| A data grid\n| `pyb-ag-grid`.\n\n| An editable panel on a supplier tab\n| `CardFormgroupComponent` with a unique `formGroupKey`.\n\n| An inline field edit\n| The specific `inline-edit/` component.\n\n| A confirmation\n| `DialogService` + `ConfirmDialogComponent`, or `ReasonDialogComponent` when a\n  reason must be recorded.\n\n| A user-facing error\n| `ErrorVisualizationService.showError({ name, errorObject })`.\n\n| Subscription cleanup\n| Extend `SubscriberComponent`.\n\n| Reference data (states, countries, timezones)\n| Dispatch `FetchUtilAttribute` and select from the `utils` slice — do not call\n  `LookupRepository` directly.\n\n| A CSV download\n| `CsvExportService.exportCsv` with a prepared string, or the report `make_dump`\n  path for large exports.\n\n| Telling another component to refresh\n| `TableEventsService`, registering the listener once.\n|===\n\n== Adding to `shared/`\n\n. Confirm the need is genuinely application-wide. If only one module uses it, it\n  belongs in that module's `shared/`.\n. Check the older generations first — especially for tables and dialogs.\n. Create `shared/<name>/` with the component, template, styles and spec.\n. Declare **and export** it in `SharedModule`.\n. Avoid `ViewEncapsulation.None` unless the styles genuinely must be global\n  (`AppStageInfoComponent`, `TaskCountBadgeComponent`, `SelectorFormComponent` and\n  the event-rule forms use it — the pattern is not a recommendation).\n. Do not import from a feature module. The two existing violations\n  (`EvaCommunicationService` and `LookupRepository` reaching into\n  `suppliers/model`) are debt, not precedent — see\n  xref:architecture:overview.adoc[architectural overview].\n. Document it here.\n"},{"component":"internal-admin","module":"ops","path":"ops:local-development.adoc","title":"Local development","description":"Getting the application running on your machine, running it against EVA, and the constraints that bite on the first try.","url":"https://docx.payability.com/internal-admin/ops/local-development.html","navOrder":44,"source":"= Local development\n:description: Getting the application running on your machine, running it against EVA, and the constraints that bite on the first try.\n\n== Prerequisites\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Requirement | Detail\n\n| Node {node-version}\n| Pinned in `.nvmrc`. Run `nvm use` in the repository root. Newer Node **will not\n  build this project**.\n\n| Python 3\n| `deployment/setenv.sh` calls `python3 ./deployment/generate_gci.py` for\n  non-release environments.\n\n| Chrome\n| Supplied by Puppeteer for headless unit tests; a real Chrome for development.\n\n| Repository access\n| `git clone git@github.com:Payability/internal-client-v2.git`\n|===\n\n[NOTE]\n====\nThe repository README references `nvm install 12.13.0` while `.nvmrc` pins\n`12.21.0`. Trust `.nvmrc`.\n====\n\n== Setup\n\n[source,bash]\n----\nnvm use                 # pin Node 12.21.0\nnpm install             # also installs Husky hooks via the postinstall script\n----\n\nThen *generate the environment file* — this is the step people forget:\n\n[source,bash]\n----\n./deployment/setenv.sh <ENV_NAME> [DOMAIN] [CONFIG_DIR] [ENV_FILE_NAME]\n\n# example, pointing at the `exp` dev environment\n./deployment/setenv.sh exp \"dev.payability.com\" \"$PWD/deployment\"\n----\n\n`npm start` runs `setenv.sh` for you with whatever environment name is currently\ncommitted in `package.json`, then `ng serve`:\n\n[source,bash]\n----\nnpm start\n----\n\n[IMPORTANT]\n====\nThe environment name in the `start` script is *committed to `package.json`* and\nchanges as developers point at different backends. Check what it currently says\nbefore running `npm start` — if it is not the environment you want, run\n`setenv.sh` explicitly instead, or you will silently develop against someone\nelse's backend.\n====\n\nTo switch backend later, re-run `setenv.sh` with a different name (`staging`,\n`apps`, …). `./deployment/checkenv.sh` prints which environment you configured last.\n\nDetails of what the script generates: xref:environments.adoc[environments].\n\n== Commands\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Command | Does\n\n| `npm start`\n| `setenv.sh` then `ng serve` on port 4200.\n\n| `npm run lint` / `npm run lint:fix`\n| ESLint over `src/app`.\n\n| `npm run format`\n| Prettier write over `src`.\n\n| `npm test`\n| Karma in watch mode.\n\n| `npm run test-single`\n| Karma once — **prefer this** in agent and CI loops.\n\n| `npm run test:ci`\n| Headless with coverage and JUnit/Cobertura reports.\n\n| `npm run build` / `build-prod` / `build-test`\n| Angular builds. The prod/test variants go through `ng-high-mem`\n  (`--max_old_space_size=8192`).\n\n| `npm run cy:open` / `cy:run`\n| Cypress interactive / headless.\n\n| `npm run cy:open-local` / `cy:run-local` / `cy:run-local-no-watch`\n| Start the app then run Cypress against it (`start-server-and-test`).\n\n| `npm run connect-to-eva`\n| Prepare an EVA-aware environment and serve.\n\n| `npm run start-with-eva`\n| The above, plus open the EVA dev server in a second terminal tab.\n|===\n\n== Ports\n\nGoogle SSO only works on ports registered with the Google client. From the\nrepository README:\n\n[source]\n----\nhttp://localhost:4200   (default)\nhttp://localhost:4300\nhttp://localhost:4400\nhttp://localhost:4500\nhttp://localhost:4600   (used by EVA)\nhttp://localhost:5000\nhttp://localhost:8082\n----\n\n[WARNING]\n====\nServing on any other port breaks login: the Google client id\n(`env.googleAuthClientId`) is registered only for these origins, and it is the same\nclient id in dev and production. If sign-in silently fails, check the port first.\n====\n\nNote 4600 is EVA's dev server port, so do not use it for this application when\nrunning both.\n\n== Incognito mode is not supported\n\nStated explicitly in the repository README. The application depends on\n`localStorage`, `sessionStorage` and a cross-subdomain cookie for the EVA token —\nsee xref:platform:caching-and-persistence.adoc[caching and persistence].\n\n== Running against a local EVA\n\nParts of the UI are rendered by EVA. To develop against a local EVA checkout:\n\n[source,bash]\n----\nnpm run start-with-eva\n----\n\nwhich expands to:\n\n[source,bash]\n----\nexport EVA_API_CUSTOM_URL=http://localhost:4600\n./deployment/setenv.sh exp dev.payability.com \"$PWD/deployment\" environment.with-eva\n# … then serves this app and, via `ttab`, runs `npm run start-with-ia` in ../eva-fe\n----\n\nSo it expects an `eva-fe` checkout as a *sibling directory*.\n\n`npm run connect-to-eva` does the environment preparation and serves this app only —\nuse it when EVA is already running.\n\nTwo behavioural differences on `localhost`:\n\n* The EVA auth token travels in the iframe URL (`?auth=<jwt>`) instead of a cookie on\n  the shared parent domain.\n* Iframe origin checks treat `localhost` as always allowed.\n\nBoth are in xref:platform:iframe-messaging.adoc[iframe messaging and EVA]. A bug\nthat only reproduces locally is often one of these.\n\n`EVA_API_CUSTOM_URL` is honoured by `deployment/set-env.ts`:\n\n[source,typescript]\n----\nevaApiUrl: '${process.env.EVA_API_CUSTOM_URL ? process.env.EVA_API_CUSTOM_URL : process.env.EVA_API}/',\n----\n\n=== Iframe resizing\n\nThe repository README documents the contract: a same-named `ResizeIframeService`\nexists in both applications. In the default direction EVA sends width/height (and\noptionally a selector for a *different* frame) and Internal Admin resizes.\n\n== Husky hooks\n\nInstalled by `npm install` (the `postinstall` script runs `husky install`).\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Hook | Runs\n\n| `pre-commit`\n| `npm run lint:staged` (`eslint --fix` on staged `*.ts` via `lint-staged`) then\n  `npm run format:staged` (`pretty-quick --staged`).\n\n| `pre-push`\n| `npm run test-single`.\n|===\n\n[IMPORTANT]\n====\nDo not bypass with `--no-verify`. If a hook is failing, fix the cause. The hooks are\nthe only automated gate on a branch before CI.\n====\n\n== Feature flags locally\n\nBuild flags come from `environment.featureFlags`, which `setenv.sh` regenerates —\nso editing it by hand is temporary. For local experimentation prefer the runtime\nconsole at `/admin/feature-flags` (needs the `DEV` permission), which stores\noverrides in `sessionStorage`.\n\nIn non-production builds the resolved flag table is printed to the console once per\npage load. See xref:platform:feature-flags.adoc[feature flags].\n\n== Useful development tooling\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Tool | Detail\n\n| Redux DevTools\n| Instrumented unconditionally. The fastest way to understand any screen — every\n  meaningful transition is a named action.\n\n| `/admin/feature-flags`\n| Runtime flag console for this app *and* EVA.\n\n| Iframe message log\n| `[IA] Sending` / `[IA] Received` in the console on non-production builds.\n\n| Request ring buffer\n| Last 50 requests in `state.errors.cachedRequests`.\n\n| `./deployment/checkenv.sh`\n| Which environment you last configured.\n|===\n\n== SonarLint (optional)\n\nThe repository README documents connecting an IDE to SonarCloud:\n\n. Generate a token in SonarCloud (`My Account` → `Security`).\n. Install the **SonarLint** extension (it requires Java).\n. In VS Code user settings:\n+\n[source,json]\n----\n\"sonarlint.connectedMode.connections.sonarcloud\": [\n  { \"connectionId\": \"payability\", \"organizationKey\": \"payability\", \"token\": \"GENERATED_TOKEN\" }\n]\n----\n\nWebStorm instructions are a `// TODO` in the README.\n\n== First-run failures\n\nThe recurring ones, with fixes, are in xref:troubleshooting.adoc[troubleshooting].\nThe short version: almost every \"it will not build\" is a missing\n`setenv.sh` run or the wrong Node version.\n"},{"component":"internal-admin","module":"ops","path":"ops:environments.adoc","title":"Environments and configuration","description":"How `src/environments/environment.ts` is generated, what the dev and production templates set, and how to add a configuration key.","url":"https://docx.payability.com/internal-admin/ops/environments.html","navOrder":45,"source":"= Environments and configuration\n:description: How `src/environments/environment.ts` is generated, what the dev and production templates set, and how to add a configuration key.\n\n== The generation chain\n\nConfiguration is *generated*, not hand-written. Four files are involved:\n\n[source]\n----\ndeployment/setenv.sh                      ← you run this\n  │  ENV_NAME [DOMAIN] [CONFIG_DIR] [ENV_FILE_NAME]\n  │\n  ├─ if ENV_NAME == \"release\":\n  │     THIS_NODE_ENV=production\n  │     source deployment/setup_template-production.cfg\n  │  else:\n  │     GCI = python3 deployment/generate_gci.py     (from ENV_NAME)\n  │     THIS_NODE_ENV = staging | development\n  │     source deployment/setup_template-dev.cfg\n  │\n  ├─ ts-node deployment/set-env.ts\n  │     reads the exported FRONTEND_* / MARKETPLACE_API / EVA_API / … variables\n  │     + `git rev-parse HEAD`\n  │     writes src/environments/environment.{prod|test}.ts\n  │     writes deployment/lastPath.txt  (LAST_PATH=<that file>)\n  │\n  ├─ source deployment/lastPath.txt\n  ├─ cp $LAST_PATH src/environments/$ENV_FILE_NAME.ts    (default: environment.ts)\n  └─ echo \"$ENV_NAME\" > deployment/lastEnv.txt           (read by checkenv.sh)\n----\n\n[IMPORTANT]\n====\n`src/environments/environment.ts` is *generated output*. Hand-editing it works locally\nand is then silently overwritten by the next `setenv.sh` run — and never takes effect\nin CI, which regenerates from the templates. All the environment files are\n`.gitignore`d:\n\n[source]\n----\n/src/environments/environment.test.ts\n/src/environments/environment.ts\n/src/environments/environment.production.ts\n/src/environments/environment.prod.ts\n/src/environments/environment.with-eva.ts\n----\n\nOnly `environment.interface.ts` is tracked.\n====\n\n== `setenv.sh` arguments\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Argument | Default | Meaning\n\n| `ENV_NAME`\n| *required*\n| The environment name, e.g. `exp`, `staging`, `apps`, or `release` for production.\n\n| `DOMAIN`\n| `dev.payability.com`\n| Top-level domain for integration URLs. Use `payability.com` for production.\n\n| `CONFIG_DIR`\n| `$PWD/deployment`\n| Where the `.cfg` templates live.\n\n| `ENV_FILE_NAME`\n| `environment`\n| Which file under `src/environments` to write (without extension). `npm run\n  prepare-env-with-eva` passes `environment.with-eva`.\n|===\n\n[WARNING]\n====\nIt is a bash script with positional arguments — you cannot supply the third without\nthe second. The script's own message puts it plainly: \"bash is too silly for this\".\n====\n\n`ENV_NAME` also decides which template is sourced and what `envName` ends up as:\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| `ENV_NAME` | Template | `THIS_NODE_ENV` | generated `envName`\n\n| `release`\n| `setup_template-production.cfg`\n| `production`\n| `prod`\n\n| `staging`\n| `setup_template-dev.cfg`\n| `staging`\n| `test`\n\n| anything else\n| `setup_template-dev.cfg`\n| `development`\n| `test`\n|===\n\n[NOTE]\n====\nOnly `release` produces `envName: 'prod'`. Everything else — including `staging` —\nproduces `'test'`. Code that branches on `envName` (the theme colour option, the stage\nbanner, `showStoreDebugger`) therefore treats staging as a test environment.\n\n`environment.production.ts` in a checkout can show `production: true` alongside\n`envName: 'test'`, because `production` is derived from the *template* while `envName`\ncomes from the same `release`-or-not check at a different point. Read\n`deployment/set-env.ts` rather than inferring.\n====\n\n== The GCI\n\nFor non-release environments `setenv.sh` computes a *GCI* — a workflow correlation\nid for the B2B task manager:\n\n[source,python]\n----\n# deployment/generate_gci.py\nsha1 = hashlib.sha1(env_name.encode())\ndecimals = re.findall('\\d', sha1.hexdigest())[:6]\nprint(int(''.join(decimals)))\n----\n\nIt takes the first six digits of the SHA-1 hex digest of `ENV_NAME`, so each\nenvironment name maps deterministically to a number. Production hardcodes\n`FRONTEND_TASK_MANAGER_GCI=1`.\n\nThis is why Python 3 is a build prerequisite. See\nxref:platform:realtime.adoc[real-time streams] for what the GCI is used for.\n\n== The templates\n\nTwo live templates plus one dead one.\n\n=== `setup_template-dev.cfg`\n\n[source,bash]\n----\nFRONTEND_SUPPLIER_URL=\"https://${ENV_NAME}-supplier.${DOMAIN}\"\nFRONTEND_TASK_MANAGER=\"https://${ENV_NAME}.${DOMAIN}/b2btaskmanager\"\nFRONTEND_TASK_MANAGER_GCI=\"${GCI}\"\nFRONTEND_INTERNAL_URL=\"https://${ENV_NAME}.${DOMAIN}/database_v2\"\nFRONTEND_INTERNAL_API_URL=\"/internal\"\n\nFRONTEND_ACTION_TOOLS_API_URL=\"https://vn1s47jzkd.execute-api.us-east-1.amazonaws.com/stage\"\nFRONTEND_B2B_DOCUMENTS_API_URL=\"https://${ENV_NAME}.${DOMAIN}/b2bdocument\"\nFRONTEND_PYB_DOCUMENTS_API_URL=\"https://${ENV_NAME}.${DOMAIN}/pyb-documents\"\nFRONTEND_DOCUMENTS_API_URL=\"https://ajwk785vec.execute-api.us-east-1.amazonaws.com/dev\"\nFRONTEND_EVENT_LOGGER_API_URL=\"https://epu44ua492.execute-api.us-east-1.amazonaws.com/test_rules2\"\nFRONTEND_EVENT_RULES_OLD_API_URL=\"https://drkrpkdxe4.execute-api.us-east-1.amazonaws.com/stage\"\nFRONTEND_MESSAGING_API_URL=\"https://argumexh08.execute-api.us-east-1.amazonaws.com/stage\"\nFRONTEND_AUTH_TOOLS_API_URL=\"https://0vd7bg72e4.execute-api.us-east-1.amazonaws.com/stage\"\nFRONTEND_REFERRAL_URL=\"https://go-test.payability.com\"\nFRONTEND_SEGMENTIO_CODE=\"qQtIVnv1eERcK7bsQs8ITA48ak8kzxA3\"\nNODE_ENV=dev\n\nMARKETPLACE_API=\"https://marketplace-api-dev2.payability.dev\"\nAUTOMATIC_UW_TOOL_API=\"https://${ENV_NAME}.${DOMAIN}/automatic-uw-tool-web-api\"\nS3_FILES_PROVIDER_API=\"https://${ENV_NAME}.${DOMAIN}/s3-files-provider\"\nEVA_API=\"https://${ENV_NAME}-eva-fe.${DOMAIN}\"\n----\n\nNote the pattern: services *hosted per environment* interpolate\n`$\\{ENV_NAME}.$\\{DOMAIN}`; shared Lambda-backed services are fixed API Gateway URLs and\nare therefore **shared across every dev environment**.\n\n=== `setup_template-production.cfg`\n\n[source,bash]\n----\nFRONTEND_SUPPLIER_URL=\"https://supplier.payability.com\"\nFRONTEND_TASK_MANAGER=\"https://workflow.payability.com/b2btaskmanager\"\nFRONTEND_TASK_MANAGER_GCI=1\nFRONTEND_INTERNAL_URL=\"https://dbapi-internal.payability.com\"\nFRONTEND_INTERNAL_API_URL=\"/bin/internal\"\n\nFRONTEND_ACTION_TOOLS_API_URL=\"https://api.payability.com/actionstools\"\nFRONTEND_B2B_DOCUMENTS_API_URL=\"https://doc-api.payability.com/b2bdocument\"\nFRONTEND_PYB_DOCUMENTS_API_URL=\"https://pyb-documents.payability.com\"\nFRONTEND_DOCUMENTS_API_URL=\"https://api.payability.com/documents\"\nFRONTEND_EVENT_LOGGER_API_URL=\"https://api.payability.com/eventlogger_v2\"\nFRONTEND_EVENT_RULES_OLD_API_URL=\"https://api.payability.com/eventlogger\"\nFRONTEND_MESSAGING_API_URL=\"https://api.payability.com/messaging\"\nFRONTEND_AUTH_TOOLS_API_URL=\"https://api.payability.com/authtools\"\nFRONTEND_REFERRAL_URL=\"https://goto.payability.com\"\nFRONTEND_SEGMENTIO_CODE=\"J8O1DVU1ToteAHLWlfsqlD2uosWt5zhA\"\nNODE_ENV=prod\n\nMARKETPLACE_API=\"https://marketplace-api.payability.com\"\nAUTOMATIC_UW_TOOL_API=\"https://automatic-uw-tool.payability.com\"\nS3_FILES_PROVIDER_API=\"https://automatic…\"   # see the file\nEVA_API=\"https://prod-eva-fe.payability.com\"\n----\n\nStructural differences from dev:\n\n* The internal API base path is `/bin/internal`, not `/internal`, and the host is\n  `dbapi-internal.payability.com` rather than `<env>.<domain>/database_v2`.\n* The event logger is `eventlogger_v2` on production while `eventRulesOldUrl` is\n  `eventlogger` — the two generations are clearly separated there, and less so on dev.\n* A different Segment write key.\n\n=== `setup_template-legacy.cfg`\n\nNot sourced by `setenv.sh`. It references `test2-api.payability.com`,\n`workflow-test`, `b2btaskmanager2` and a hardcoded `GCI=12` — an older environment\nshape. Dead file; do not use it as a reference for current URLs.\n\n== Values `set-env.ts` adds itself\n\nNot from the templates:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Source\n\n| `production`\n| `environment === 'prod'`.\n\n| `envName`\n| `'prod'` or `'test'`.\n\n| `VERSION`\n| `require('../../package.json').version` — evaluated at *runtime* in the browser,\n  not baked in as a literal.\n\n| `googleAuthClientId`\n| **Hardcoded in `set-env.ts`** — the same client id for every environment.\n\n| `sentryUrl`\n| Also hardcoded, and unused by any code. See\n  xref:platform:observability.adoc[observability].\n\n| `instanceId`\n| Hardcoded `8001`.\n\n| `supplierSiteVersions`\n| A hardcoded array: `c2fo`, `morecommerce`, `neweggcapital`,\n  `neweggcapital2`, `onboarding`, `onboarding1`, `supplier`, `supplier2`,\n  `supplier3`, `supplier4`, `volusion`.\n\n| `displayErrorsInConsole` / `displayErrorsPopup`\n| Hardcoded `true` / `false`.\n\n| `featureFlags`\n| `process.env.FEATURE_FLAGS` or `''`. **Neither template exports it**, so build flags\n  are empty unless the pipeline sets it. Flags are managed through global settings\n  instead — see xref:platform:feature-flags.adoc[feature flags].\n\n| `gitHash`\n| `git rev-parse HEAD`, first 40 characters.\n\n| `lastBuildDate`\n| `new Date().toISOString().substr(0, 16)` — ISO to the minute.\n\n| `domain`\n| `process.env.DOMAIN`. Used for the EVA cookie and iframe origin checks.\n|===\n\n== The double mechanism for production builds\n\nTwo things swap the environment file, and knowing both avoids confusion:\n\n. `setenv.sh` copies the generated file over `src/environments/environment.ts`.\n. `angular.json`'s `production` configuration *also* declares a file replacement:\n+\n[source,json]\n----\n\"fileReplacements\": [\n  { \"replace\": \"src/environments/environment.ts\", \"with\": \"src/environments/environment.prod.ts\" }\n]\n----\n\nSo a production build reads `environment.prod.ts` — which `set-env.ts` wrote — while\n`environment.ts` holds a copy of the same content. The `staging` and `development`\nconfigurations declare no replacement and use `environment.ts` directly.\n\n== `IEnvironment`\n\n`src/environments/environment.interface.ts` is the tracked contract — 33 keys. The\nfull table with dev and production values is in\nxref:reference:environment-variables.adoc[environment configuration keys]; what each\nbackend is for is in\nxref:architecture:integration-landscape.adoc[the integration landscape].\n\n== Reading configuration in code\n\nAlways through `AppConfigService`:\n\n[source,typescript]\n----\nconstructor(private appConfig: AppConfigService, private http: HttpClient) {\n  this.apiUrl = appConfig.env.internalApiUrl;\n}\n----\n\n`AppConfigService` is provided by factory in `AppModule`:\n\n[source,typescript]\n----\nexport const initAppConfig = () =>\n  new AppConfigService('0.0.1', themeConfigData(environment.envName), environment);\n----\n\n[NOTE]\n====\nThe `'0.0.1'` version argument is vestigial — the user-visible version comes from\n`environment.VERSION` via `VersionService`, not from `AppConfigService.version`.\n====\n\nA handful of services import `environment` directly instead —\n`EventsService`, `EvaService`, `LookupRepository`, `PendingReviewService`,\n`FeatureFlagsService`, `GatewayService`, `MarketplaceRepository`,\n`VpnTesterService`, `CommunicationService`, `VersionService`. New code should take\n`AppConfigService`.\n\n== Adding a configuration key\n\nAll four steps, or it breaks in CI while working locally:\n\n. Add the key to `src/environments/environment.interface.ts`.\n. Export the shell variable in **both** `deployment/setup_template-dev.cfg` and\n  `deployment/setup_template-production.cfg`.\n. Add the field to the generated template literal in `deployment/set-env.ts`.\n. Re-run `./deployment/setenv.sh <env>` locally to regenerate.\n\nThen read it through `AppConfigService`, and document it in\nxref:reference:environment-variables.adoc[the environment reference] and\nxref:architecture:integration-landscape.adoc[the integration landscape].\n\n[WARNING]\n====\nSkipping step 2 or 3 produces the classic failure: the key is populated in your local\nfile (because you edited it) and `undefined` in every deployed build. TypeScript will\nnot catch it — the interface is satisfied by the generated file, which simply omits\nthe property.\n====\n\n== Checking which environment you are on\n\n[source,bash]\n----\n./deployment/checkenv.sh      # prints deployment/lastEnv.txt\n----\n\nAt runtime, `shared/layout/app-stage-info/` is designed to show the environment,\nconnected backend, git hash and build date — but is currently disabled\n(`getStageName()` returns `of('')`). The sidebar footer still shows front-end and\nback-end versions. See xref:platform:observability.adoc#versions[versions].\n"},{"component":"internal-admin","module":"ops","path":"ops:build-and-deploy.adoc","title":"Build and deployment","description":"Build configurations, the CodeBuild specs, how assets reach S3, and how a release is versioned.","url":"https://docx.payability.com/internal-admin/ops/build-and-deploy.html","navOrder":46,"source":"= Build and deployment\n:description: Build configurations, the CodeBuild specs, how assets reach S3, and how a release is versioned.\n\n== Overview\n\n[source]\n----\n  git push\n    │\n    ▼\nAWS CodePipeline  (one pipeline per environment: internal-client-v2-<env>)\n    │\n    ├── buildspec.yml            build stage\n    │     npm ci\n    │     npm run lint\n    │     # npm run test:ci      ← currently commented out\n    │     ./deployment/build.sh $ENV_NAME $ENV_TYPE \"$PWD/deployment\"\n    │        ├─ npm install\n    │        ├─ . ./deployment/setenv.sh <env> <domain> <config-dir>\n    │        └─ ng build --configuration=$THIS_NODE_ENV\n    │              with --max_old_space_size=12288\n    │     artifacts: ./dist/**/*\n    │\n    └── buildspec-deploy.yml     deploy stage\n          ./deployment/deploy.sh\n            aws s3 sync $DIST → s3://$BUCKET_NAME/$BUCKET_PREFIX\n            (plus a __v<version> copy on release)\n----\n\nSeparate specs exist for CI-only checks (`buildspec-ci.yml`) and Cypress\n(`buildspec-cypress.yml`).\n\n== Angular build configurations\n\nFrom `angular.json`. Common options: `aot: true`, `outputHashing: 'all'`,\n`vendorChunk: false`, output to `dist/`, `src/index.html` as index, plus the eight\nglobal stylesheets, three global scripts and the TinyMCE asset copy.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Configuration | Settings\n\n| `development` *(default)*\n| `optimization: false`, `sourceMap: true`, `namedChunks: true`,\n  `buildOptimizer: false`. No file replacement.\n\n| `staging`\n| `optimization: true`, `sourceMap: false`, `namedChunks: false`,\n  `extractLicenses: true`, `buildOptimizer: true`. **No file replacement** — uses\n  `environment.ts` as generated.\n\n| `production`\n| As staging, plus a file replacement of\n  `src/environments/environment.ts` → `environment.prod.ts`.\n|===\n\n`THIS_NODE_ENV`, set by `setenv.sh`, selects which one `build.sh` passes to\n`ng build`: `production` for `ENV_NAME=release`, `staging` for\n`ENV_NAME=staging`, `development` otherwise. See\nxref:environments.adoc[environments].\n\n=== Custom webpack\n\n`angular.json` uses `@angular-builders/custom-webpack:browser` with\n`custom-webpack.config.js`:\n\n[source,javascript]\n----\nmodule.exports = {\n  plugins: [new webpack.DefinePlugin({ BUILD_TIMESTAMP: +new Date() })],\n  optimization: { sideEffects: false },\n};\n----\n\n`sideEffects: false` disables webpack's side-effect-based tree shaking. Given the\nnumber of modules with import-time side effects (reducers reading `localStorage`,\n`window.$` assignment, the Segment snippet), this is load-bearing — do not enable it.\n\n`BUILD_TIMESTAMP` is defined globally but not referenced by application code.\n\n=== Memory\n\nThe AOT build of ~420 components exceeds Node's default heap:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Where | Setting\n\n| `npm run ng-high-mem`\n| `--max_old_space_size=8192` — used by `build-prod` and `build-test`.\n\n| `deployment/build.sh`\n| `--max_old_space_size=12288` — the CI build.\n|===\n\nA local `npm run build` uses the plain `ng` binary and can run out of memory on large\nchanges; use `npm run build-prod` or `build-test` instead.\n\n`allowedCommonJsDependencies` suppresses CommonJS warnings for\n`ag-grid-enterprise`, `jwt-decode`, `angular2-moment`, `elasticsearch`, `rxjs` and\n`payment`.\n\n== `deployment/build.sh`\n\n[source,bash]\n----\n./deployment/build.sh ENV_NAME ENV_TYPE CONFIG_DIR [ENV_FILE_NAME]\n----\n\n`ENV_TYPE` chooses the domain:\n\n[source,bash]\n----\nif [[ \"$ENV_TYPE\" == \"dev\" ]]; then DOMAIN=dev.payability.com\nelse                                DOMAIN=payability.com; fi\n----\n\nIt then runs `npm install`, sources `setenv.sh` (note: *sourced*, so the exported\nvariables persist), prints `THIS_NODE_ENV`, and builds. `set -eux` means the script\nechoes every command and aborts on the first failure.\n\n== Buildspecs\n\n=== `buildspec.yml` — the environment build\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Phase | Commands\n\n| `install`\n| Node 12 + Python 3.8 runtimes. Fetches an SSH key pair from Secrets Manager\n  (`production/files/ssh-keys/github-zip`), installs it into `~/.ssh`, adds\n  `github.com` to `known_hosts`, then `npm ci`.\n\n| `build`\n| `npm run lint`, then\n  `./deployment/build.sh $ENV_NAME $ENV_TYPE \"$PWD/deployment\"`.\n\n| `artifacts`\n| `./dist/**/*`\n|===\n\n[WARNING]\n====\n`npm run test:ci` is present but **commented out** in this spec:\n\n[source,yaml]\n----\n- npm run lint\n- # npm run test:ci\n- ./deployment/build.sh …\n----\n\nSo the deploy path runs *lint only*. Unit tests run in `buildspec-ci.yml`, which is a\nseparate pipeline — do not assume a deployed build had its tests run.\n====\n\nThe SSH key is needed because `npm ci` resolves at least one dependency over SSH.\n\n=== `buildspec-ci.yml` — checks only\n\n[source,yaml]\n----\ninstall: nodejs 12; npm ci\nbuild:   npm run lint && npm run test:ci\nreports:\n  karma-junit-report: TESTS.xml            (JUNITXML)\n  cobertura-report:   coverage/cobertura-coverage.xml\nartifacts: coverage/lcov-report/**/*\n----\n\nNo build, no deploy. This is the gate that actually runs the unit suite.\n\n=== `buildspec-cypress.yml` — E2E\n\nPulls `NPM_TOKEN` and `CYPRESS_QA_TEAM_PASSWORD` from Secrets Manager\n(`internal-client-v2/cypress:QA_TEAM_PASSWORD`), configures the GitHub npm registry,\nthen either runs against a locally served app or a deployed one:\n\n[source,bash]\n----\nif [ $CYPRESS_LOCAL = \"true\" ]; then\n  ./deployment/setenv.sh $CYPRESS_ENV_NAME \"dev.payability.com\" \"$PWD/deployment\"\n  npm run cy:run-local-no-watch\nelse\n  npm run cy:run\nfi\n----\n\n`post_build` merges the JUnit reports. Artifacts include reports, screenshots and\nvideos. The suite is marked *not maintained* — see xref:testing.adoc[testing].\n\n=== `buildspec-deploy.yml`\n\nOne line: `./deployment/deploy.sh`.\n\n== `deployment/deploy.sh`\n\n[source,bash]\n----\nDIST_FOLDER=$CODEBUILD_SRC_DIR_BuildArtifact/dist\nS3_LOCATION=s3://$BUCKET_NAME/$BUCKET_PREFIX\n\n# deploy as the default (latest) version\naws s3 sync $DIST_FOLDER $S3_LOCATION --acl public-read --delete \\\n            --exclude \"*__v*\" --region us-east-1\n\n# OPS-131: also deploy to a versioned subfolder for release\nif [[ \"$ENV_NAME\" == \"release\" ]]; then\n  CURRENT_VERSION=$(jq -r '.version' ./package.json)\n  aws s3 sync $DIST_FOLDER $S3_LOCATION/__v${CURRENT_VERSION} \\\n              --acl public-read --delete --region us-east-1\nfi\n----\n\nTwo things to note:\n\n* `--delete` removes anything in the bucket prefix that is not in the artifact, but\n  `--exclude \"*__v*\"` protects the versioned release folders from being swept away.\n* On `release`, the same assets are *also* published to\n  `__v<package.json version>`, which is what allows switching between released\n  versions without a rebuild (ticket OPS-131). Bumping `package.json` is therefore\n  meaningful on release.\n\n`--acl public-read`: the built assets are public. Nothing secret may be in the bundle\n— which is worth remembering given the hardcoded event-logger token noted in\nxref:platform:observability.adoc[observability].\n\n== Environments and pipelines\n\nFrom the repository README:\n\n* All dev environments deploy from `{default-branch}` by default.\n* Production is released by opening a pull request to `{release-branch}`.\n* To point an environment at your branch: AWS Console → CodePipeline (dev account) →\n  the pipeline named `internal-client-v2-<env>` → *Edit* → *Edit stage (source)* →\n  the GitHub block → set *Branch name* → *Done* → *Save*. Every new HEAD on that\n  branch then triggers a deployment; *Release change* forces one.\n* Dev assets land in the `dev-payability-www` bucket under `cdk-<env>/admin`.\n\n`deployment/reset-branches.sh` force-recreates `dev-1` … `dev-10` from\n`{default-branch}` and force-pushes them — the per-developer environment branches.\n\n[CAUTION]\n====\n`reset-branches.sh` runs `git branch -D` and `git push --force` in a loop over ten\nbranches. Do not run it casually; anyone with work on a `dev-N` branch loses it.\n====\n\n== Docker (not the deployment path)\n\nA `Dockerfile` and `docker-compose.yml` exist for local containerised runs, and they\nare *stale*:\n\n* `FROM node:8-alpine` — Node 8, not the pinned {node-version}.\n* `ng build --env=$environment \"dist-$environment\"` — the pre-Angular-6 CLI flag,\n  which the current CLI does not accept.\n* Stage 2 serves `dist` from `nginx:1.13.3-alpine` with `nginx/default.conf`.\n\n`nginx/default.conf` is still informative about the hosting contract: gzip on,\n`root /usr/share/nginx/html`, and\n\n[source,nginx]\n----\nlocation / { try_files $uri $uri/ /index.html =404; }\n----\n\nan SPA fallback to `index.html`. Note that hash routing (`useHash: true`) means deep\nlinks do not actually depend on this rewrite — see\nxref:architecture:routing.adoc[routing].\n\n== Release checklist\n\n. Merge to `{default-branch}`; confirm the environment pipeline is green.\n. Bump `version` in `package.json` — it becomes the `__v<version>` S3 folder.\n. Open a pull request to `{release-branch}` using\n  `.github/PULL_REQUEST_TEMPLATE.md`.\n. Confirm the `release` pipeline used `setup_template-production.cfg`\n  (`ENV_NAME=release`) and therefore the `production` build configuration.\n. Verify the deployed build: the sidebar footer shows the front-end and back-end\n  versions.\n\n== Gotchas\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Issue | Detail\n\n| Deploy build does not run tests\n| `test:ci` is commented out of `buildspec.yml`.\n\n| `npm install`, not `npm ci`, inside `build.sh`\n| The buildspec runs `npm ci` and then `build.sh` runs `npm install` again.\n\n| Heap size differs local vs CI\n| 8192 locally, 12288 in CI.\n\n| Docker files are stale\n| Node 8 and a removed CLI flag.\n\n| `--acl public-read`\n| Built assets are world-readable.\n\n| Only `release` gets versioned folders\n| And only if `package.json` was bumped.\n\n| `setenv.sh` is *sourced*\n| Its exports leak into the rest of `build.sh` deliberately.\n|===\n"},{"component":"internal-admin","module":"ops","path":"ops:testing.adoc","title":"Testing","description":"The unit-test setup and idioms, coverage reporting, and the current state of the Cypress suite.","url":"https://docx.payability.com/internal-admin/ops/testing.html","navOrder":47,"source":"= Testing\n:description: The unit-test setup and idioms, coverage reporting, and the current state of the Cypress suite.\n\n== What exists\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Suite | Status | Detail\n\n| Unit tests\n| **Maintained**\n| Karma + Jasmine, ~400 spec files, run by `npm run test-single` and by\n  `buildspec-ci.yml`.\n\n| E2E tests\n| **Not maintained**\n| Cypress {cypress-version}, 10 spec files. Marked *NOT MAINTAINED* in capitals in the\n  repository README.\n|===\n\n[IMPORTANT]\n====\nThe deploy buildspec has `npm run test:ci` **commented out** — only\n`buildspec-ci.yml` runs the unit suite, as a separate pipeline. A deployed build has\nnot necessarily had its tests run. See xref:build-and-deploy.adoc[build and\ndeployment].\n====\n\n== Running unit tests\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Command | Use\n\n| `npm test`\n| Watch mode, for interactive development.\n\n| `npm run test-single`\n| One run. **Prefer this** in CI and agent loops.\n\n| `npm run test:ci`\n| Headless with coverage and reports (`ChromeHeadlessNoSandbox`, `--code-coverage`).\n\n| `npm run test-double`\n| `ng lint` then a sourcemap-free single run.\n|===\n\n[NOTE]\n====\nThe repository README says `test-single` \"will also run linting test\". It does not —\nthat is `test-double`. Run `npm run lint` separately.\n====\n\n== Karma configuration\n\n`karma.conf.js`:\n\n[source,javascript]\n----\nprocess.env.CHROME_BIN = require('puppeteer').executablePath();   // Chrome comes from Puppeteer\n\nframeworks: ['jasmine', '@angular-devkit/build-angular']\nbrowsers:   ['ChromeHeadlessNoSandbox']\nclient: { jasmine: { random: false }, clearContext: false, args: ['--sourcemaps=false'] }\nreporters: ['progress', 'coverage', 'junit']\nport: 9876\nsingleRun: false\nautoWatch: true\n----\n\nPoints that matter:\n\n* **Chrome is Puppeteer's.** No system Chrome needed, but the Puppeteer install must\n  have succeeded.\n* `ChromeHeadlessNoSandbox` passes `--no-sandbox --disable-setuid-sandbox --disable-gpu\n  --disable-translate --disable-extensions --no-proxy-server` — required in\n  containers.\n* **`random: false`.** Specs run in a deterministic order, which means a spec can\n  accidentally depend on another's side effects. Given that several reducers touch\n  `localStorage` at module-evaluation time and `cerialize` casing is global state,\n  this is not hypothetical — see\n  xref:architecture:data-modeling.adoc[data modelling].\n* Coverage reporters: `html`, `lcov`, `text-summary`, `cobertura`, written to\n  `coverage/`.\n* `specReporters` is configured with `failFast: true`, but `jasmine-spec-reporter` is\n  not in the `reporters` list, so those options are inert.\n\n== `src/test.ts`\n\nThe Karma entry point. It imports the `zone.js` testing shims, initialises the Angular\n`TestBed`, then discovers specs with\n`require.context('./', true, /\\.spec\\.ts$/)` and starts Karma manually\n(`__karma__.loaded = function () {}` prevents a premature start).\n\nSo *every* `*.spec.ts` under `src/` is picked up automatically — there is no manual\nregistry.\n\n== Spec idioms\n\nThe house pattern uses `ng-bullet` and `ng-mocks`:\n\n[source,typescript]\n----\nimport { TestBed } from '@angular/core/testing';\nimport { configureTestSuite } from 'ng-bullet';\nimport { MockProvider } from 'ng-mocks';\n\ndescribe('FeatureFlagsService', () => {\n  let service: FeatureFlagsService;\n\n  configureTestSuite(() => {\n    TestBed.configureTestingModule({\n      providers: [FeatureFlagsService, MockProvider(GlobalSettingsService)],\n    });\n    service = TestBed.inject(FeatureFlagsService);\n  });\n\n  it('should be created', () => { … });\n});\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Tool | Why\n\n| `configureTestSuite` (`ng-bullet`)\n| Configures the `TestBed` **once per suite** instead of per test, which is a large\n  speed win with this many specs. Note the consequence: the module is not rebuilt\n  between tests, so state mutated in one `it` can leak into the next.\n\n| `MockProvider` / `MockComponent` / `MockModule` (`ng-mocks`)\n| Auto-mock a dependency without hand-writing a stub.\n\n| `PybMaterialModule`\n| Import this instead of thirty individual Material modules — that is its stated\n  purpose.\n\n| `*-mock.service.ts` siblings\n| ~30 hand-written doubles, provided by token. Several services have an\n  `*-abstract.service.ts` base specifically to make this substitution clean — see\n  xref:architecture:http-and-services.adoc#abstractions[service abstractions].\n\n| `*-mock.reducers.ts`\n| Store slice stubs: `auth-mock.reducers.ts`, `crm-mock.reducers.ts`,\n  `supplier-mock.reducers.ts`, `utils-mock.reducets.ts` (sic),\n  `marketplace.reducer.mock.ts`.\n\n| `mocks/` module\n| Shared test doubles and mock pipes.\n\n| `ToastrMockService`\n| `shared/services/toastr-mock/` — stubs notifications.\n|===\n\n=== Naming convention for test constants\n\nFrom the repository's own conventions: production constants are `UPPERCASE`;\nconstants that exist **only for tests or mocks** are wrapped in double underscores.\n\n[source,typescript]\n----\nconst SOME_PRODUCTION_CONSTANT = 'some value';\nconst __SOME_MOCKED_CONSTANT__ = 'some value';\n----\n\n`feature-flags.service.spec.ts` follows it: `__TEST_DISABLED_FLAGS__`,\n`__TEST_PAYLOAD__`, `__TEST_MODULE1__`.\n\n=== Testing private members\n\nExisting specs reach into privates via a cast, and use `spyOn` on private methods:\n\n[source,typescript]\n----\nspyOn(service as any, 'getFeatureFlagsFromEnv').and.returnValue(__TEST_DISABLED_FLAGS__);\n(service as any).initFF();\nexpect((service as any).featureFlags.length).toEqual(0);\n----\n\nNot ideal, but it is the local convention. `getFeatureFlagsFromEnv` exists as a\nprivate method *specifically* so specs can stub the environment.\n\n== What is well tested, and what is not\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Area | State\n\n| Advance calculator\n| `calculator.spec.ts` (~560 lines) is the most thorough spec in the repository. If\n  you change a pricing formula or limit, that spec is where it must be proven. See\n  xref:domains:advances.adoc[advances].\n\n| Feature flags\n| `feature-flags.service.spec.ts` and `feature-flag.guard.spec.ts` cover the layer\n  precedence.\n\n| Card model\n| `card.model.spec.ts` covers the pure mappers and predicates — the payoff of the\n  interface-plus-functions model style.\n\n| Real-time validation\n| Both the validator and the input factory have specs and mocks.\n\n| Auth guard\n| `auth.guard.spec.ts`.\n\n| Effects\n| Thinly covered. The large effect classes (`SupplierEffects`, `CrmEffects`) have no\n  dedicated specs.\n\n| Components\n| ~400 spec files exist for ~420 components, but many are generated\n  \"should create\" smoke tests rather than behavioural.\n|===\n\n== Writing a new test\n\n. Put it next to the unit under test as `<name>.spec.ts`. It is discovered\n  automatically.\n. Use `configureTestSuite` from `ng-bullet`.\n. Mock dependencies with `ng-mocks` `MockProvider`, or provide the existing\n  `*-mock.service.ts` by token.\n. Import `PybMaterialModule` rather than individual Material modules.\n. Prefix test-only constants with `__` … `__`.\n. Assert *behaviour*, not that the component exists. A `should create` test alone does\n  not satisfy the definition of done.\n. For a reducer, follow `calculator.spec.ts`: construct the action, run the reducer,\n  assert on the returned state.\n. Run `npm run test-single` before pushing — Husky's `pre-push` hook runs it anyway.\n\n[TIP]\n====\nBecause `random: false` and `configureTestSuite` share the `TestBed` across a suite,\na spec that writes to `localStorage`, `sessionStorage`, or sets `cerialize` casing\nshould clean up after itself. Order-dependent failures usually trace back to one of\nthose three.\n====\n\n== Coverage\n\n`npm run test:ci` writes to `coverage/`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Output | Consumer\n\n| `coverage/lcov-report/**`\n| Human-readable HTML; published as a CodeBuild artifact.\n\n| `coverage/cobertura-coverage.xml`\n| CodeBuild's `cobertura-report`.\n\n| `TESTS.xml`\n| CodeBuild's `karma-junit-report` (JUnit XML). `junitReporter.useBrowserName` is\n  `false`, so filenames are stable.\n|===\n\nThere is no enforced coverage threshold.\n\n== Cypress (unmaintained)\n\n[WARNING]\n====\nThe repository README marks the E2E suite ***NOT MAINTAINED***. Treat the specs as\ndocumentation of intent rather than a working gate. Do not add a Cypress test as the\n*only* proof of a change.\n====\n\n=== Configuration\n\n`cypress.json`:\n\n[source,json]\n----\n{\n  \"env\": { \"LOCAL\": false, \"ENV_NAME\": \"staging\", \"ENV_TYPE\": \"dev\" },\n  \"chromeWebSecurity\": false,\n  \"reporter\": \"junit\",\n  \"reporterOptions\": { \"mochaFile\": \"cypress/reports/test-report-[hash].xml\" },\n  \"fixturesFolder\": false,\n  \"pluginsFile\": false,\n  \"defaultCommandTimeout\": 30000\n}\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Setting | Why\n\n| `chromeWebSecurity: false`\n| Required because the app embeds EVA in cross-origin iframes.\n\n| `defaultCommandTimeout: 30000`\n| The app is slow to settle — many screens fan out to several backends.\n\n| `LOCAL`\n| `true` targets a locally served app; `false` targets a deployed environment.\n\n| `ENV_NAME` / `ENV_TYPE`\n| Which environment to point at.\n\n| `QA_TEAM_PASSWORD`\n| **Not in the file** — it must not be versioned. Retrieve it from Secrets Manager\n  (`internal-client-v2/cypress`) or put it in a local, untracked\n  `cypress.env.json`.\n|===\n\n=== Commands\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Command | Does\n\n| `npm run cy:open` / `cy:run`\n| Interactive / headless against the configured environment.\n\n| `npm run cy:open-local` / `cy:run-local`\n| Start the app (`start-server-and-test` on port 4200) then run Cypress with\n  `--env LOCAL=true`.\n\n| `npm run cy:run-local-no-watch`\n| As above with `npm start -- --watch false` — the CI variant.\n\n| `npm run cy:merge-reports`\n| `jrm` merges `cypress/reports/*.xml` into `merged-test-report.xml` (required by\n  CodeBuild).\n\n| `npm run cy:clear-reports`\n| `rimraf cypress/reports`.\n|===\n\nNote the README refers to `cy:clean-reports`; the script is `cy:clear-reports`.\n\n=== Suite layout\n\n[source]\n----\ncypress/\n├── constants.js          URLS, QA_TEAM_CREDENTIALS, FINAL_ENV, ENV_VAR_KEYS\n├── utils.js\n├── support/\n│   ├── commands.js       custom commands, incl. setupToken()\n│   └── index.js\n└── integration/\n    ├── login.spec.js\n    ├── search.spec.js\n    ├── crm-notes.spec.js\n    ├── marketplaces.spec.js\n    ├── eva-menu.spec.js\n    ├── awx-disputes.spec.js\n    └── general/\n        ├── change-password.spec.js\n        ├── contact-data-form.spec.js\n        ├── supplier-main-bar.spec.js\n        └── suspension.spec.js\n----\n\n`support/commands.js` imports **from the application source**:\n\n[source,javascript]\n----\nimport { AMAZON_SELLER_CENTRAL_MARKETPLACE_KEY } from '../../src/app/suppliers/…/supplier-onboarding.service';\nimport { AMAZON_SELLER_CENTRAL } from '../../src/app/shared/enums/marketplace.enum';\n----\n\nSo renaming those exports breaks the E2E suite silently. It also holds an\n`ffTempStorage = { eva: null, ia: null }` for saving and restoring feature flags\naround a test, and `setupToken()` authenticates by calling the internal API's\n`auth/login` with a Basic header built from the QA credentials — the same flow as\nxref:platform:authentication.adoc[authentication].\n\n=== Driving the real backend, or stubbing it\n\nMost specs act against a deployed environment and the `TEST_CYPRESS_SUPPLIER` fixture,\nundoing whatever they create. `awx-disputes.spec.js` does not: it stubs the three Airwallex\ndispute endpoints its create-then-submit path touches — list, create and submit — with\n`cy.intercept`, swapping the list response as the flow progresses so each reload reflects\nthe mutation. Evidence upload and cancel are covered by unit tests rather than here.\n\nThe reason is that the flow is not reversible. An Airwallex dispute cannot be deleted —\ncancelling is the only unwind, and only before it reaches `IN_PROGRESS` — so a live run\nwould leave permanent records on the test supplier and would not be idempotent. See\nxref:domains:cards.adoc#awx-disputes[Airwallex disputes].\n\nPrefer the real backend when a flow can clean up after itself, and stub when it cannot.\n\n=== `data-test` attributes\n\nThe convention, from the README: whenever a test needs to act on an element, add\n`data-test=\"uniqueName\"` to it. The sidebar is thoroughly annotated\n(`sidebarSearch`, `sidebarActions`, `sidebarMarketplaces`, …).\n\n[CAUTION]\n====\n`data-test` values are not guaranteed unique. The Manage menu uses\n`data-test=\"sidebarContracts\"` for *both* the Contracts and the Month End Close items —\na copy-paste error that would make a selector ambiguous. See\nxref:domains:manage.adoc[manage].\n====\n\n== Definition of done, testing part\n\nNew behaviour needs a unit test. A user-facing flow *should* have a Cypress test, but\ngiven the suite's state, a unit test plus a described manual check is the practical\nbar. See xref:conventions.adoc[conventions] and the definition of done in\n`CLAUDE.md`.\n"},{"component":"internal-admin","module":"ops","path":"ops:conventions.adoc","title":"Code conventions","description":"Naming, structure, SCSS, lint and format rules, and the Git and PR conventions this repository enforces.","url":"https://docx.payability.com/internal-admin/ops/conventions.html","navOrder":48,"source":"= Code conventions\n:description: Naming, structure, SCSS, lint and format rules, and the Git and PR conventions this repository enforces.\n\nThe governing principle, stated in the repository's own README: *the code should be\nas transparent as possible*, following Clean Code, SOLID, DRY and YAGNI. In practice\nthe operative rule is narrower — **find the nearest existing pattern and follow it**.\n\n== Inherited standards\n\nThe repository declares that it follows\nhttps://www.w3.org/wiki/JavaScript_best_practices[the W3C JavaScript best practices]\nand inherits most TypeScript conventions from\nhttps://github.com/airbnb/javascript[the Airbnb style guide], with the exceptions\nbelow.\n\n=== No Hungarian notation\n\nExcept on interfaces and types.\n\n[source,typescript]\n----\n// bad\nlet sName: string;\nlet iYears: number;\nlet oConfig = { … };\n\n// good\nlet name: string;\nlet years: number;\nlet config = { … };\n----\n\n[NOTE]\n====\nInterfaces and types *do* carry a prefix in places — `IEnvironment`,\n`IBalanceApproval`, `IResult`, `IPaymentGateway`, `ISupplierDetailsDTO`. Newer files\ntend to drop it (`EmailPreference`, `CardBase`, `AirwallexCardholderStatus`). Both\nexist; match the neighbouring file.\n====\n\n=== Constant naming\n\n`UPPERCASE` for constants — especially genuinely immutable ones. When unsure, prefer\n`UPPERCASE`.\n\nTest- and mock-only constants are wrapped in double underscores:\n\n[source,typescript]\n----\nconst SOME_PRODUCTION_CONSTANT = 'some value';\nconst __SOME_MOCKED_CONSTANT__ = 'some value';\n----\n\nThis is a real convention, followed in `feature-flags.service.spec.ts`\n(`__TEST_DISABLED_FLAGS__`, `__TEST_PAYLOAD__`). See\nxref:testing.adoc[testing].\n\n== TypeScript and Angular rules\n\nEnforced by ESLint (`.eslintrc.js`, generated from an earlier TSLint config).\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Rule | Setting\n\n| `@angular-eslint/component-selector`\n| Element selector, `app` prefix, kebab-case — `app-supplier-general`.\n\n| `@angular-eslint/directive-selector`\n| Attribute selector, `app` prefix, camelCase — `appEnabledByFeatureFlag`.\n\n| `@angular-eslint/component-class-suffix` / `directive-class-suffix`\n| `…Component` / `…Directive`.\n\n| `@angular-eslint/use-lifecycle-interface`\n| Implement `OnInit` etc., don't just declare the method.\n\n| `@angular-eslint/use-pipe-transform-interface`\n| Pipes implement `PipeTransform`.\n\n| `@typescript-eslint/member-ordering`\n| Enforced ordering of class members.\n\n| `@typescript-eslint/no-explicit-any`\n| **Off.** `any` is permitted and widespread in older code — new code should still be\n  typed.\n\n| `@typescript-eslint/ban-types`\n| Warns on `Object` and `Function`.\n\n| `simple-import-sort/imports` and `/exports`\n| Import ordering is *enforced*. Do not hand-order imports; run `npm run lint:fix`.\n\n| `unused-imports/no-unused-imports-ts`\n| Unused imports are removed by `--fix`.\n\n| `unused-imports/no-unused-vars-ts`\n| Unused variables warn; `_`-prefixed names are exempt\n  (`varsIgnorePattern`/`argsIgnorePattern` of `^_`).\n\n| `max-len`\n| 140 columns, with `^import .*` lines exempt.\n\n| `no-console`\n| Warns, but allows a long list including `log`, `warn`, `error`, `group`,\n  `groupEnd`, `dir` — which is why `console.log` appears in shipped code.\n\n| `complexity`\n| Off.\n\n| `@typescript-eslint/naming-convention`\n| Present but **commented out** in the config.\n|===\n\nAlmost every rule is set to `warn`, not `error` — so `npm run lint` can pass with\nwarnings. Do not add new warnings; the signal is already noisy.\n\n== Prettier\n\nPrettier is authoritative for formatting. `.prettierrc`:\n\n[source,json]\n----\n{\n  \"printWidth\": 140,\n  \"singleQuote\": true,\n  \"useTabs\": false,\n  \"tabWidth\": 2,\n  \"semi\": true,\n  \"bracketSpacing\": true,\n  \"trailingComma\": \"all\",\n  \"jsxBracketSameLine\": true\n}\n----\n\nRun `npm run format` before finishing. `.prettierignore` excludes `dist/`,\n`coverage/`, `node_modules/`, `.vscode/`, `.idea/`, `package-lock.json` and\n`package.json`.\n\n`.editorconfig` matches: UTF-8, spaces, indent 2, final newline, trim trailing\nwhitespace (except in Markdown).\n\n== TypeScript compiler options\n\n`tsconfig.json` — note what is *not* enabled:\n\n[source,json]\n----\n{\n  \"target\": \"es2015\", \"module\": \"es2020\", \"lib\": [\"es2017\", \"dom\"],\n  \"experimentalDecorators\": true, \"emitDecoratorMetadata\": true,\n  \"resolveJsonModule\": true, \"allowSyntheticDefaultImports\": true,\n  \"downlevelIteration\": true, \"importHelpers\": true,\n  \"skipLibCheck\": true, \"noStrictGenericChecks\": true,\n  \"paths\": { \"payment\": [\"../node_modules/payment\"] }\n}\n----\n\nThere is no `strict`, `strictNullChecks`, `noImplicitAny` or\n`strictPropertyInitialization`. `noStrictGenericChecks` is explicitly *on*, relaxing\ngeneric assignability. Turning any of these on is a repository-wide change, not a\nper-file one.\n\n== Structure\n\nThe repository's stated position, worth reading in full because it deliberately\ndiverges from Angular's own guide:\n\n* \"The blindly and dogmatically applied 'as flat as possible' principle leads to\n  designs that are very difficult to maintain and modify.\"\n* Folder structure \"should logically reflect the technological and business structure\n  of the project and the intention of its creators.\"\n* Subcomponents go in a folder with their main component; more than a few helpers\n  (constants, utils, interfaces) get their own folder.\n* If a project follows an organisational framework, the structure should reflect *that\n  one* framework — do not mix (for example atomic design with fractal structure).\n* Component selectors should match the component name.\n* Use a component as an **element**, never as an attribute.\n\nThe concrete shape is\nhttps://antontelesh.github.io/architecture/2016/03/16/fractal-architecture.html[fractal\narchitecture]: `model/`, `services/`, `store/`, `shared/` recur at each level, and\nplacement is decided by *scope*. See\nxref:architecture:module-map.adoc[the module map].\n\n[NOTE]\n====\nDeviations exist \"rather by mistake, not by design\" — the README says so. Both\n`service/` and `services/` folder spellings occur; match the module you are in.\n====\n\n== SCSS naming — ABEM without prefixes\n\nFor new code: *camelCase BEM with separately-applied modifiers* — described in the\nREADME as \"ABEM without atomic prefixes\", because the app is not atomic.\n\n[source,html]\n----\n<div class=\"someLongNameOfBlock__someElementName -modifier -modifier2\">…</div>\n\n<section class=\"uwCard__subtitle -active -selected -primary -green -small\">…</section>\n----\n\nSo: camelCase inside blocks and elements, `__` between block and element, and\nmodifiers as *separate* classes prefixed with `-` rather than appended to the element\nclass.\n\nThe README is candid that the existing CSS is \"vague and incoherent\" in places, and\nthat most of it is some BEM mutation. Use ABEM for new code; do not rewrite old\nselectors while doing something else.\n\nGlobal styles are the eight SCSS entry points in `angular.json`\n(`styles`, `bootstrap`, `font-awesome`, `layout`, `loader`, `material2-theme`,\n`theme`, `ui`), with `src/assets` on the include path. Avoid\n`ViewEncapsulation.None` unless the styles genuinely must be global — see\nxref:platform:shared-library.adoc[shared library].\n\n== Service and state conventions\n\nSummarised here; the detail is in\nxref:architecture:http-and-services.adoc[HTTP layer and services] and\nxref:architecture:state-management.adoc[state management].\n\n* Services are thin `HttpClient` wrappers. Base URLs come from `AppConfigService`\n  (`appConfig.env.<key>`) — **never hardcoded**.\n* Return `Observable<T>`; map the response shape inside the service.\n* Auth is transparent: `AuthInterceptor` attaches the session token. Do not\n  re-implement it.\n* Models are plain interfaces or `cerialize`-decorated classes in `model/`, one\n  concept per file.\n* Reach for NgRx only where the feature already uses it. Feature-local state stays in\n  the component.\n* Extend `SubscriberComponent` and push into `this.subscriptions` for cleanup.\n\n== Git conventions\n\nBranch names have three slash-separated parts:\n\n[source]\n----\n{e-mail identifier (part before @)} / {TICKET-ID} / {short-ticket-description}\n\npiotrk/NEW-11/very-important-task\nolek/IA-69/even-more-important-task\nmichael/GAR-2051/email-preference\n----\n\nContractors prefix the identifier with a lowercase `c` and capitalise the name:\n\n[source]\n----\ncElvis/NEW-42/ultimate-question-of-life-the-universe-and-everything\ncVika/IA-4242/most-important-task\n----\n\nTicket ids are `UPPERCASE`, as in Jira. Projects seen in this repository: `GAR`,\n`TS`, `DREAM` (historically also `IA`, `NEW`, `APPS`, `OPS`).\n\nCommit messages are short and imperative.\n\n`{default-branch}` is the integration branch; production is released by pull request\nto `{release-branch}`. See xref:build-and-deploy.adoc[build and deployment].\n\n== Pull requests\n\nFill in `.github/PULL_REQUEST_TEMPLATE.md`. Its sections:\n\n. *Description* — summary, motivation, context, dependencies.\n. *Jira ticket(s)* — a link.\n. *Type of change* — hot fix / bug fix / new feature / breaking change, plus a\n  **\"This change requires a documentation update\"** checkbox.\n. *How has this been tested?* — the tests you ran and how to reproduce.\n. *Checklist* — self-review; follows the *Change Management Policy* and style\n  guidelines; comments added in hard-to-understand areas;\n  **corresponding changes to the documentation have been added**; tests added;\n  unit tests pass; dependent changes merged.\n. *QA sign off*.\n\n[IMPORTANT]\n====\nTwo of those items are about documentation, and this repository's documentation is the\nAntora component in `docs/`. Use the `docs-writer` agent/skill (phase 5) to satisfy\nthem — see xref:spec-driven-workflow.adoc[the spec-driven workflow].\n====\n\n== Hooks\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Hook | Runs\n\n| `pre-commit`\n| `npm run lint:staged` (`eslint --fix` on staged `*.ts`) then\n  `npm run format:staged` (`pretty-quick --staged`).\n\n| `pre-push`\n| `npm run test-single`.\n|===\n\nNever bypass with `--no-verify`.\n\n== What not to do\n\n* Do not bump dependencies, change the Angular version, introduce a second state\n  library, or swap the test runner. See\n  xref:architecture:decisions.adoc[decisions and constraints].\n* Do not hardcode a base URL.\n* Do not add a store slice for state one component owns.\n* Do not edit `src/environments/environment.ts` — it is generated. See\n  xref:environments.adoc[environments].\n* Do not edit the committed `*.js` / `*.js.map` build residue that sits beside `.ts`\n  sources; edit the `.ts`.\n* Do not \"fix\" load-bearing misspellings without checking. `'analitics'`,\n  `WALLMART_MARKETPLACE_KEY`, `/proceses`, `UDPATE_SUPPLIER_PAYMENT_CONFIGS` and\n  `superceded` are on the wire or in live URLs.\n* Do not rewrite an unrelated file's formatting or selectors while making a change.\n\n== A general preference that does not apply here\n\nThe developer's general preference is Next.js / Python tooling. That does **not**\napply to this repository — it is Angular, and Angular conventions win here. Stated\nexplicitly in `CLAUDE.md`.\n"},{"component":"internal-admin","module":"ops","path":"ops:spec-driven-workflow.adoc","title":"Spec-driven workflow","description":"The five agent phases, the `.specs/` formats, the checkpoints, and where the tool-specific definitions live.","url":"https://docx.payability.com/internal-admin/ops/spec-driven-workflow.html","navOrder":49,"source":"= Spec-driven workflow\n:description: The five agent phases, the `.specs/` formats, the checkpoints, and where the tool-specific definitions live.\n\n== The principle\n\nNon-trivial changes are *specified before they are implemented*. The agreed\n`.specs/<feature>.md` file is the source of truth for the change. If reality\ncontradicts it mid-implementation, work stops and the spec is updated — the code does\nnot quietly diverge.\n\nThis is stated in `CLAUDE.md` / `AGENTS.md` as a standing decision. See\nxref:architecture:decisions.adoc[decision D10].\n\n== The five phases\n\n[source]\n----\n      ┌──────────┐    ┌───────────────┐    ┌─────────────┐    ┌────────────────────────┐    ┌─────────────┐\n ──►  │ planner  │──► │ plan-reviewer │──► │ implementer │──► │ implementation-reviewer│──► │ docs-writer │\n      └──────────┘    └───────┬───────┘    └──────┬──────┘    └───────────┬────────────┘    └─────────────┘\n       writes           critiques the         executes in         reviews the diff          updates the\n       .specs/<f>.md    spec BEFORE code      small verified      against the spec          Antora docs\n                              │               increments                │                    in docs/\n                              │                   │                     │\n                              └───────────────────┴─────────────────────┘\n                                    kick back on any contradiction\n----\n\nPhases *auto-chain with checkpoints*: each hands off to the next, but the implementer\nworks incrementally and **stops** on any failing lint or test, or on any decision that\ncontradicts the spec.\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Phase | Role | Stops and asks when\n\n| 1\n| **Planner** — produce or extend a spec with requirements, design and a file-level\n  task list.\n| The requirement is ambiguous, or the change needs a decision the user must make.\n\n| 2\n| **Plan reviewer** — critique the spec for correctness, scope, completeness and fit\n  with existing Angular/NgRx patterns. *Before any code.*\n| The plan invents architecture, or misses an existing pattern.\n\n| 3\n| **Implementer** — execute the approved spec one task at a time, verifying each step.\n| Lint or a test fails non-trivially; the spec is wrong; a task needs an unspecced\n  dependency; scope is growing.\n\n| 4\n| **Implementation reviewer** — review the diff against the spec and the correctness\n  properties. Verdict: `APPROVED` or `CHANGES REQUESTED`, with numbered findings\n  citing `file:line`, marked blocking or nit.\n| Blocking findings route back to the implementer.\n\n| 5\n| **Docs writer** — update the Antora documentation component in `docs/` to match what\n  actually shipped.\n| The code contradicts the spec in a way that changes documented behaviour; a new\n  Antora module or `antora.yml` change is needed; the implementation is too unclear to\n  document truthfully.\n|===\n\n== Where the definitions live\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Tool | Definitions\n\n| Claude Code\n| `.claude/agents/{planner,plan-reviewer,implementer,implementation-reviewer,docs-writer}.md`\n  and\n  `.claude/skills/{planner,plan-reviewer,implementer,implementation-reviewer,docs-writer}/SKILL.md`\n\n| Cursor\n| `.cursor/rules/{10-planner,20-plan-reviewer,30-implementer,40-implementation-reviewer,50-docs-writer}.mdc`,\n  plus `.cursor/rules/00-project.mdc` (always-on context),\n  `.cursor/rules/update-docs.md`, `.cursor/agents/*.md` and `.cursor/skills/*/SKILL.md`\n\n| Shared context\n| `CLAUDE.md` (Claude Code) and `AGENTS.md` (everything else) — mirrored content.\n|===\n\nCursor additionally carries `angular-architecture`, `change-plan-authoring`,\n`change-plan-review` and `post-plan-code-review` skills, which predate the five-phase\nnaming.\n\n== Operating principles\n\nThese shape *how* each phase works, not just what it produces. From `CLAUDE.md`,\nafter Andrej Karpathy:\n\nKeep the model on a tight leash::\nMany small, concrete, verifiable steps over one big speculative diff. Change one\nthing, prove it, move on.\n\nOptimise the generation→verification loop::\nEvery change should be cheap to check — a small diff, a runnable test, a lint pass. If\na human or a reviewer agent cannot verify a step quickly, the step is too big.\n\nContext is RAM::\nPull in only the files that matter. Reference exact paths and symbols. Do not dump a\nwhole module into context \"just in case\".\n\nSpec before code::\nDo not start editing until the plan exists and survives review.\n\nNever ship code you cannot explain::\nNo \"vibe\" diffs. Every change is justified against the spec and covered by a test or a\nclear manual check.\n\nMatch the codebase, do not impress it::\nConcrete and consistent beats clever.\n\nKeep the human at the decision points::\nSurface trade-offs and ambiguities; never silently choose on anything that changes\nscope or behaviour.\n\n== `.specs/` formats\n\nThere are two reference formats and a third, longer planning format. All three are\nlive; pick by size of change.\n\n=== 1. Requirements-and-tasks (for new work)\n\n`.specs/bank-name-swift-lookup.md`:\n\n[source]\n----\n# <Feature Name>\n\n## Summary        one paragraph: what and why\n## Requirements   numbered, testable\n## Design         the approach, existing patterns to follow, files involved\n## Tasks          - [ ] checkboxes, file-level, in order\n----\n\nThe implementer checks off each task (`- [x]`) as it goes, so the spec doubles as\nprogress.\n\n=== 2. As-built record (for shipped work)\n\n`.specs/email-preference-management.md`:\n\n[source]\n----\n# <Feature Name>\n\n## Overview             what it is, where it lives in the UI\n## Files Created        each path with a one-line purpose\n## Files Modified       each path with what changed\n## API Endpoints        method, URL, request/response shape, auth note\n## Component Behaviour  loading, error and edge-case behaviour\n----\n\nNote how specific it is — down to\n\"`supplierKey` visibility changed from `private` to `public`\" and\n\"401/403 → access denied message, 404 → not found message, 5xx/network → generic error\n+ retry button\". That level of detail is what makes the review phase cheap.\n\n=== 3. Plan documents (for large changes)\n\n`.specs/gar-2149-card-management-airwallex.plan.md` and\n`.specs/airwallex_cardholder_registration_72c94cb6.plan.md` use a fuller structure:\n\n[source]\n----\n## Goals / non-goals / done\n## Affected surfaces\n## Documentation consulted\n## Design\n   ### API Migration Map\n   ### Unified Card Model\n   ### Provider Detection Flow\n## Implementation sequence\n   ### Phase 1: Model & Interfaces\n   ### Phase 2: Feature Flag\n   ### Phase 3: Service Layer\n   ### Phase 4: NgRx Store (minimal additions)\n   ### Phase 5: Component Updates\n   ### Phase 6: Routing\n   ### Phase 7: Module Registration\n   ### Phase 8: Tests\n## Files and resources\n   ### New files / Modified files / Env vars\n   ### New `USER_PERMISSION` values\n   ### New `FEATURE_FLAG` key\n## Risk and scale\n## Testing and rollout\n   ### Manual QA paths\n----\n\nTwo things worth copying from that format:\n\n* *Non-goals* are stated explicitly, which is what keeps the implementer's scope\n  honest.\n* New `USER_PERMISSION` values, new `FEATURE_FLAG` keys and new env vars get their own\n  sections — exactly the items that also require a documentation catalogue update. See\n  the blast-radius checklist below.\n\nThe result of that particular plan is documented in\nxref:domains:cards.adoc[card programmes].\n\n== The documentation phase\n\nPhase 5 is new relative to the original four-phase workflow, and exists because\n`docs/` is a structured Antora component rather than a README.\n\n`docs/` is an Antora *documentation component* named `internal-admin`, consumed by the\ncentral Payability playbook via `start_path: docs`. Six modules: `ROOT`,\n`architecture`, `domains`, `platform`, `ops`, `reference`. See\n`docs/README.adoc` and xref:ROOT:documentation-map.adoc[the documentation map].\n\nThe `docs-writer` skill carries the AsciiDoc/Antora reference (resource-ID xrefs,\n`nav.adoc` upkeep, the page template) and a *blast-radius checklist*. The short\nversion:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| If the change touched… | Update\n\n| a route, guard or route `data`\n| `reference/routes.adoc` + the owning `domains/` page\n\n| an NgRx action, reducer or effect\n| `reference/store-actions.adoc`\n\n| a service or endpoint\n| `reference/services-and-endpoints.adoc` + the owning `domains/` page\n\n| `IEnvironment`, `set-env.ts`, a `.cfg` template\n| `reference/environment-variables.adoc` + `ops/environments.adoc` +\n  `architecture/integration-landscape.adoc`\n\n| `FEATURE_FLAGS`\n| `reference/feature-flag-catalog.adoc` + `platform/feature-flags.adoc`\n\n| `USER_PERMISSION`\n| `reference/permission-catalog.adoc` + `platform/authorization.adoc`\n\n| `MESSAGE_TYPE` or an event type\n| `reference/event-catalog.adoc` + `platform/events.adoc` or\n  `platform/iframe-messaging.adoc`\n\n| a storage key, cookie or cache\n| `platform/caching-and-persistence.adoc`\n\n| a new module or shared component\n| `architecture/module-map.adoc`, `platform/shared-library.adoc`,\n  `reference/inventory.adoc`\n\n| a dependency or build config\n| `architecture/tech-stack.adoc` + `ops/build-and-deploy.adoc`\n|===\n\nThe docs-writer is *documentation-only* — it never edits `src/`. Where the code and a\npage disagree, the code wins and the page is the bug.\n\n== Definition of done\n\nA change is done when:\n\n* the spec's acceptance criteria are met;\n* `npm run lint` and `npm run test-single` pass;\n* new behaviour has a unit test (and a Cypress test if it is a user-facing flow —\n  noting that the E2E suite is unmaintained, see xref:testing.adoc[testing]);\n* the spec in `.specs/` is updated to match what shipped;\n* the Antora docs in `docs/` are updated;\n* `.github/PULL_REQUEST_TEMPLATE.md` is filled in — including the\n  \"corresponding changes to the documentation have been added\" checkbox;\n* the diff is small enough to review in one sitting.\n\n== Using the phases\n\nInvoke by name, or let the chain run:\n\n[source]\n----\n\"plan this\"            / \"spec this ticket\" / a Jira key with no code   → planner\n\"review this plan\"     / \"is this plan ready\"                          → plan-reviewer\n\"implement this spec\"  / \"build the approved plan\"                      → implementer\n\"review this diff\"     / \"is this ready to merge\"                       → implementation-reviewer\n\"update the docs\"      / \"document this change\"                         → docs-writer\n----\n\nFor a trivial fix — a typo, a one-line guard — the full chain is overkill. The bar is\n\"non-trivial\": anything that adds a file, changes behaviour, or touches an item in the\nblast-radius checklist.\n"},{"component":"internal-admin","module":"ops","path":"ops:troubleshooting.adoc","title":"Troubleshooting","description":"The recurring failure modes — build, runtime, auth, EVA, flags and tests — with the fix for each.","url":"https://docx.payability.com/internal-admin/ops/troubleshooting.html","navOrder":50,"source":"= Troubleshooting\n:description: The recurring failure modes — build, runtime, auth, EVA, flags and tests — with the fix for each.\n\n== Build and setup\n\n=== `TypeError: Cannot read property 'length' of undefined at createSourceFile`\n\n*Cause.* The environment file has not been generated.\n\n*Fix.*\n\n[source,bash]\n----\n./deployment/setenv.sh exp \"dev.payability.com\" \"$PWD/deployment\"\n----\n\nThis is the single most common first-run failure. See\nxref:environments.adoc[environments].\n\n=== `The .../src/environments/environment.prod.ts path in file replacements does not exist`\n\n*Cause.* Same root cause, hit from the production build path — `angular.json`'s\n`production` configuration replaces `environment.ts` with `environment.prod.ts`, which\n`set-env.ts` writes only when `ENV_NAME=release`.\n\n*Fix.* Run `setenv.sh` (with `release` if you genuinely want a production build).\n\n=== `Cannot read property 'config' of null` on `ng serve --open` or `npm test`\n\n*Cause.* Node/CLI version mismatch, usually after switching Node versions.\n\n*Fix.*\n\n[source,bash]\n----\nnvm list\nnvm use            # picks up .nvmrc → 12.21.0\nnpm i\n----\n\nIf that does not help, reinstall the CLI:\n\n[source,bash]\n----\nsudo npm uninstall -g @angular/cli\nsudo npm install   -g @angular/cli\n----\n\nThe README documents a workaround for stubborn uninstalls: switch to an older Node\n(`nvm use v8.11.3`), uninstall, switch back, reinstall.\n\n=== SCSS / sass errors\n\n*Fix.* Check Node and npm versions first, then:\n\n[source,bash]\n----\nsudo npm rebuild node-sass\n----\n\n=== The build runs out of memory\n\n*Cause.* AOT compiling ~420 components exceeds Node's default heap.\n\n*Fix.* Use the high-memory scripts rather than plain `ng build`:\n\n[source,bash]\n----\nnpm run build-prod     # or build-test — both use --max_old_space_size=8192\n----\n\nCI uses 12288 (`deployment/build.sh`). See\nxref:build-and-deploy.adoc[build and deployment].\n\n=== `python3: command not found` during `setenv.sh`\n\n*Cause.* `generate_gci.py` computes the workflow GCI for non-release environments.\n\n*Fix.* Install Python 3. Only `ENV_NAME=release` skips this step.\n\n=== Husky hooks are not running\n\n*Cause.* `husky install` runs in `postinstall`; it is skipped if `npm install` was run\nwith `--ignore-scripts`.\n\n*Fix.* `npm run init:husky`.\n\n== Runtime and login\n\n=== Google sign-in silently does nothing\n\n*Cause.* You are serving on a port that is not registered with the Google client.\n\n*Fix.* Use one of 4200, 4300, 4400, 4500, 4600, 5000, 8082 — and remember 4600 is\nEVA's dev port. See xref:local-development.adoc[local development].\n\n=== \"Cannot connect with server\" on login\n\n*Cause.* Wrong backend, or the backend is down. `LoginComponent` maps other statuses\ndistinctly: `401` → \"Invalid login or password\", `403` → \"Access denied\".\n\n*Fix.* Check which environment you configured (`./deployment/checkenv.sh`) and whether\nthat backend is up.\n\n=== \"Something change in google auth response\"\n\n*Cause.* The Google credential was absent from the callback payload —\n`AuthEffects.authLoginGoogle` guards against Google changing its response shape.\n\n*Fix.* Check for a Google Identity Services change and for third-party-cookie or\ntracking-prevention settings blocking the flow.\n\n=== Logged out unexpectedly, or bounced to `/login` on navigation\n\n*Causes.*\n\n* The JWT expired. `RouteAccessResolver` decodes it on every guarded navigation and\n  logs out when the `token` claim is in the past.\n* A request returned `401`; `AuthInterceptor` dispatches `Logout` with\n  \"Auth token expired, please try to login again.\"\n* The user record has no `loginId`.\n\nThere is *no token refresh*. See xref:platform:authentication.adoc[authentication].\n\n=== After login I land on the wrong page\n\n*Cause.* `sessionStorage['loginRedirectUrl']` is set by `RouteAccessResolver` and\n**never cleared after use**, so a stale value can survive.\n\n*Fix.* Clear `sessionStorage` for the origin.\n\n=== The app does not work in incognito\n\n*Expected.* Stated in the README. The app depends on `localStorage`,\n`sessionStorage` and a cross-subdomain cookie.\n\n== Data looks stale\n\n=== A supplier shows old values\n\n*Cause.* `supplier.suppliers[supplierKey]` caches every supplier visited this session\nand nothing invalidates it.\n\n*Fix.* Trigger a refresh (`FetchSupplierDetails({ supplierKey, refresh: true })` —\nwhich also appends `refreshTimestamp` to bust any HTTP cache), or reload the page. See\nxref:domains:suppliers.adoc[suppliers].\n\n=== A newly created user is missing from an assignee dropdown\n\n*Cause.* `UsersService.getPreparedCombinedList()` caches users and groups in a\n`BehaviorSubject` for the whole session, with no invalidation.\n\n*Fix.* Reload. See\nxref:platform:caching-and-persistence.adoc[caching and persistence].\n\n=== The supplier list only shows a few suppliers\n\n*Expected.* With no persisted query, the default is\n`created_since:<today − 1 day>`. Change the query; it is persisted to\n`localStorage['suppliers_last_query']`.\n\n=== Reference data (states, countries) is wrong\n\n*Cause.* The `utils` slice is cached in `localStorage` via `lscache` with a 24-hour\nTTL.\n\n*Fix.* Dispatch `FetchUtilAttribute` with `refresh`, or clear the\n`lscache-utilsState` key.\n\n=== A displayed ratio does not match the API response\n\n*Cause.* On the balance-approval screen, a non-zero\n`short_to_long_mov_avg_ratio_rcv` silently *overwrites*\n`short_to_long_mov_avg_ratio`. See\nxref:domains:balance-approval.adoc[balance approval].\n\n== Feature flags\n\n=== A flag is on in the bundle but the feature is off\n\n*Cause.* Global settings beat build flags — the resolved set is seeded from the\n`FEATURE_FLAGS_IA` global setting, not from `environment.featureFlags`.\n\n*Fix.* Enable it at `/admin/feature-flags` (needs `DEV`) and save to global settings,\nor set a session override.\n\n=== My session flag overrides disappeared\n\n*Cause.* Someone saved to global settings, which stamps a newer `lastChange` than your\nstored session timestamp — and session overrides older than the setting are discarded.\n\n*Fix.* Re-apply them. See xref:platform:feature-flags.adoc[feature flags].\n\n=== `isEnabled()` returns false when the flag should be on\n\n*Cause.* `isEnabled` reads build flags plus `sessionStorage` only. It is correct\n*after* `FeatureFlagsGeneralGuard` has run `initFF()` on a guarded navigation. Calling\nit from a constructor that runs earlier (as `AppComponent` does for `CHECK_VPN`) can\nread `false` on a cold load.\n\n*Fix.* Move the check later in the lifecycle, or into a guard.\n\n=== Toggling a flag does not re-render a component\n\n*Expected.* `appEnabledByFeatureFlag` evaluates once, in its input setter. Reload.\n\n=== A menu item is missing but the URL works\n\n*Expected* for flags applied only in the sidebar. `FeatureFlagsGeneralGuard` always\npasses; only `FeatureFlagGuard` (used on two routes) redirects to `/404`.\n\n== Permissions\n\n=== A user cannot reach a page they should be able to\n\n*Check, in order.*\n\n. Does the user hold `REST_UW_ACCESS` or `ADVANCE_AUDITORS`? Those are *deny filters*\n  applied across nearly every route and menu item.\n. Does the route use an allow clause? Only `/admin` (`only: DEV`) and\n  `underwriting/background-reports` (`only: FINANCE`) do — and an allow-list blocks\n  even super users without that exact code.\n. Is the grant *supplier-scoped*? `ngx-permissions` discards the `supplierKey` scope,\n  so route and template gating are scope-blind while the backend is not.\n\nSee xref:platform:authorization.adoc[authorisation].\n\n=== \"Access denied.\" toast then redirect to `/`\n\n*Cause.* `SubPermissionsGuard` — the route requires one of\n`data.requireOneOfSubPermissions` and the user has none. It also denies when the route\ndata is missing or empty.\n\n=== A `REST_UW_ACCESS` user sees a stripped-down UI\n\n*Expected.* `AppComponent` adds a `restricted-access` class to `document.body`, which\nSCSS uses to hide UI wholesale, and the supplier aggregate load skips several calls for\n`ADVANCE_AUDITORS`.\n\n== EVA and iframes\n\n=== An embedded region is blank\n\n*Check.*\n\n. The auth token: cookie `PYBEvaToken` on `environment.domain` when deployed, or\n  `?auth=<jwt>` in the iframe URL on `localhost`.\n. The console for `[IA] Sending` / `[IA] Received` — no `Received` lines means messages\n  are being dropped by the iframe-id or origin checks.\n. Whether EVA itself is up (`env.evaApiUrl`).\n\nSee xref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\n=== It works locally but not deployed (or vice versa)\n\n*Cause.* Local and deployed differ in *where the token lives* — query parameter versus\ncookie — and origin checks always allow `localhost` on non-production builds.\n\n=== An iframe stops resizing\n\n*Cause.* `ResizeIframeService.getElement` throws `'Element not found!'` when the target\nframe is gone, and the throw terminates the `incomingMessage$` subscription — so *all*\nsubsequent resize handling stops.\n\n*Fix.* Reload.\n\n=== `document.domain` errors in the console\n\n*Expected.* `CommunicationService.setDomain()` assigns `document.domain` before every\nsend, wrapped in an empty `catch`. The API is deprecated in modern browsers.\n\n== Long-running jobs\n\n=== Progress stops updating during a Load Earnings / Load Remittance\n\n*Cause.* `SseService` propagates any `onerror` or JSON parse failure as an observable\nerror, which **terminates the stream**. There is no reconnect.\n\n*Fix.* Reload to re-open the stream. See\nxref:platform:realtime.adoc[real-time streams].\n\n=== A callback fires repeatedly / grows over time\n\n*Cause.* `B2bWorkflowService.subscribeTask` creates a new inner subscription per\nreceived message and never tears them down.\n\n*Fix.* Subscribe to `taskMonitoring()` directly and filter yourself.\n\n== Payments and processes\n\n=== `/processes/approval` shows the wrong screen\n\n*Expected.* `:supKey` is declared before `approval` in the `/processes` table, so the\nURL matches `PendingEditComponent`.\n\n*Fix.* Use `/proceses/approval` — one `s`. See\nxref:domains:processes.adoc[processes].\n\n=== Two error toasts for one failure\n\n*Expected.* `PendingInterceptorService` toasts any 5xx, and the calling service or\ncomponent toasts as well. `PaymentBatchRepositoryService` adds its own message per\noperation. See xref:platform:error-handling.adoc[error handling].\n\n=== Provider filtering on the card list appears ignored\n\n*Cause.* `CardManagementService.getCards` sends the provider filter as a **body on a\nGET request**, which some proxies drop.\n\nSee xref:domains:cards.adoc[card programmes].\n\n== Tests\n\n=== Tests pass individually but fail together\n\n*Cause.* Karma runs with `jasmine.random: false` and `ng-bullet`'s\n`configureTestSuite` shares the `TestBed` across a suite. A spec that writes to\n`localStorage`/`sessionStorage`, or sets `cerialize` casing via\n`DeserializeKeysFrom`/`SerializeKeysTo`, leaks into the next.\n\n*Fix.* Clean up in `afterEach`. See xref:testing.adoc[testing].\n\n=== Karma cannot find Chrome\n\n*Cause.* `karma.conf.js` sets `CHROME_BIN` from Puppeteer's bundled binary.\n\n*Fix.* Reinstall so the Puppeteer download completes (`npm install`), or set\n`CHROME_BIN` yourself.\n\n=== A deployed build regressed something the tests cover\n\n*Cause.* `npm run test:ci` is **commented out** of `buildspec.yml`. Only\n`buildspec-ci.yml` runs the unit suite, as a separate pipeline.\n\n=== Cypress specs fail after a rename\n\n*Cause.* `cypress/support/commands.js` imports from application source\n(`AMAZON_SELLER_CENTRAL_MARKETPLACE_KEY`, `AMAZON_SELLER_CENTRAL`). The suite is also\nmarked unmaintained.\n\n== Console noise that is expected\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Message | Why\n\n| AG Grid enterprise licence warning\n| The key embedded in `main.ts` has an expired `ExpiryDate`. Functionality is\n  unaffected.\n\n| `[Feature Flags] Current Functionality Status: …`\n| `FeatureFlagsGeneralGuard` prints the resolved table once per page load in\n  non-production.\n\n| `[IA] Sending …` / `[IA] Received …`\n| `CommunicationService` logging the EVA protocol in non-production.\n\n| `------- SILENT LOG DEBUG --------`\n| The `Logger` helper, dev-only.\n\n| `Elasticsearch is disabled. No events will be shown.`\n| The event-rule editor's example-events query is intentionally stubbed.\n\n| `Please turn on the Payability VPN…` every 30 seconds\n| `VpnTesterService` toasts on every failed probe while `CHECK_VPN` is on.\n\n| Duplicate `.js` hits when grepping\n| Committed build residue sits beside `.ts` sources. Exclude `*.js` from searches.\n|===\n\n== When nothing else works\n\n. `nvm use && rm -rf node_modules && npm install`\n. `./deployment/setenv.sh <env> \"dev.payability.com\" \"$PWD/deployment\"`\n. Clear `localStorage`, `sessionStorage` and cookies for the origin.\n. `npm run lint && npm run test-single` to confirm the tree itself is sound.\n. Check `./deployment/checkenv.sh` — you may be pointed at a backend that is down.\n"},{"component":"internal-admin","module":"reference","path":"reference:routes.adoc","title":"Route catalogue","description":"Every route in the application, its component, guards and permission clause.","url":"https://docx.payability.com/internal-admin/reference/routes.html","navOrder":51,"source":"= Route catalogue\n:description: Every route in the application, its component, guards and permission clause.\n\nDerived from the route tables listed below. Regenerate this page whenever a route\nchanges — see the blast-radius checklist in the `docs-writer` skill.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Route table | Aggregated as\n\n| `{source-root}/app-routes.module.ts`\n| The root — nests every feature table under `MainLayoutComponent`, plus the `''`\n  redirect to `/welcome` and the `**` wildcard to `PageNotFoundComponent`.\n\n| `auth/auth-routing.module.ts`\n| `RouterModule.forChild` — renders *outside* the layout shell.\n\n| `shared/shared-routing.module.ts`\n| `RouterModule.forChild`.\n\n| `admin/admin.routes.ts`, `app-welcome/app-welcome-routing/app-welcome-routing.routes.ts`,\n  `crm-tools/tasks-routing.routes.ts`, `emails/emails-routing.routes.ts`,\n  `manage/manage-routing.routes.ts`, `marketplaces/marketplaces-routing.routes.ts`,\n  `processes/processes-routing.routes.ts`, `reports/reports-routing.routes.ts`,\n  `suppliers/suppliers-routing.routes.ts`, `underwriting/underwriting-routing.routes.ts`\n| Spread into `generalAppRoutes`.\n|===\n\nRouter options: `useHash: true`, `scrollPositionRestoration: 'enabled'`,\n`relativeLinkResolution: 'legacy'`, `enableTracing: false`. See\nxref:architecture:routing.adoc[routing].\n\n== Notation\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Shorthand | Meaning\n\n| **STD**\n| `canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard, NgxPermissionsGuard]`\n  with `data.permissions = { except: [REST_UW_ACCESS, ADVANCE_AUDITORS] }`\n\n| **UW-CFG**\n| `RESTRICTED_UW_ACCESS_CONFIG` — denies `REST_UW_ACCESS` + `ADVANCE_AUDITORS`\n\n| **AUD-CFG**\n| `RESTRICTED_AUDITORS_ACCESS_CONFIG` — denies `REST_UW_ACCESS` only\n\n| **RUW-CFG**\n| `RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG` — denies `ADVANCE_AUDITORS` only\n\n| **MPS-CFG**\n| `RESTRICTED_UW_ACCESS_CONFIG_MARKETPLACE_STATEMENTS` — UW-CFG plus report metadata\n|===\n\n== Outside the layout shell\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Path | Component | Guards\n\n| `/login`\n| `LoginComponent`\n| none\n\n| `/logout`\n| `LogoutComponent`\n| none\n\n| `/reset-password`\n| `ResetPasswordComponent`\n| none\n|===\n\n== Welcome\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Path | Component | Guards\n\n| `/welcome`\n| `WelcomeComponent`\n| `AuthGuard`, `FeatureFlagsGeneralGuard`\n\n| `/welcome/:messageCode`\n| `WelcomeComponent`\n| same\n|===\n\n`:messageCode` renders a canned message from `MessageService`\n(`INSTALL_EXTENSION`, `CANT_RECEIVE_SUPPLIER_TOKEN`,\n`YOU_CAN_ONLY_LOG_IN_TO_SUPPLIER_SUPPLIER4`).\n\n== Root redirects\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Path | Behaviour\n\n| `''`\n| Redirect to `/welcome` (declared both inside and outside the layout children,\n  `pathMatch: 'full'`).\n\n| `**`\n| `PageNotFoundComponent`.\n|===\n\n== Admin\n\nParent `/admin`: `canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard,\nNgxPermissionsGuard]`, `data.permissions = { only: DEV, redirectTo: '/' }`.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `/admin` → `''`\n| `AdminBaseComponent`\n\n| `/admin/feature-flags`\n| `AdminFeatureFlagsComponent`\n\n| `/admin/qa`\n| `AdminQaComponent`\n\n| `/admin/**`\n| redirect to `/admin/feature-flags`\n|===\n\n== Tasks\n\nParent `/tasks`: `TasksComponent`, **STD**.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `''`, `list`\n| redirect to `list/0/filter`\n\n| `list/:filterId/filter`\n| `TaskListComponent`\n|===\n\n== Emails\n\nParent `/emails`: **STD** (no parent component).\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `''`\n| redirect to `list`\n\n| `list`\n| `EmailListComponent` (from `crm-tools/emails/`)\n\n| `list/:filterId/filter`\n| `EmailPreviewComponent` (from `crm-tools/emails/`)\n|===\n\n== Manage\n\nParent `/manage`: **STD**. Default child `users/list`.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `users` (parent `UsersComponent`)\n| `''` → `list`; `list` → `UserListComponent`; `new` → `UserAddComponent`;\n  `:loginKey/edit` → `UserEditComponent`\n\n| `perm-groups` (parent `PermGroupsComponent`)\n| `''` → `list`; `list` → `PermGroupsListComponent`; `new` → `PermGroupsAddComponent`;\n  `:permGroupKey/edit` → `PermGroupsEditComponent`\n\n| `batch-imports`\n| `BatchImportsComponent`\n\n| `global-settings`\n| `GlobalSettingsComponent`\n\n| `configs`\n| `ConfigsComponent`\n\n| `event-rules`\n| `''` → `list`; `list` → `RulesListComponent`; `add` → `RulesAddEditComponent`;\n  `:eventRuleId/edit` → `RulesAddEditComponent`;\n  `:eventRuleId/history` → `RulesHistoryListComponent`\n\n| `event-rules-2`\n| `EventRules2Component`\n\n| `events`\n| `EventsComponent`\n\n| `month-end-close`\n| `MonthEndCloseComponent`\n\n| `onboarding` (parent `OnboardingComponent`)\n| `''` → `list`; `list` → `OnboardingListComponent`;\n  `add` → `OnboardingAddComponent`;\n  `:onboardingTemplateKey/edit` → `OnboardingAddComponent`;\n  `:onboardingTemplateKey/marketplaces` → `OnboardingMpListComponent`\n\n| `email-config` (parent `EmailSettingsComponent`)\n| `''` → `list`; `list` → `EmailSettingsListComponent`\n\n| `fee-settings` (parent `FeeSettingsComponent`)\n| `''` → `list`; `list` → `FeeSettingsListComponent`;\n  `add` → `FeeSettingsAddComponent`; `:id/edit` → `FeeSettingsEditComponent`\n\n| `contracts`\n| `ContractsComponent`\n|===\n\n== Marketplaces\n\nParent `/marketplaces`: **STD**.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `''`\n| redirect to `list`\n\n| `list`\n| `MarketplaceListComponent`\n\n| `add`\n| `MarketplaceComponent`\n\n| `:marketplaceKey/edit`\n| `MarketplaceComponent`\n|===\n\n== Processes\n\nTwo tables. Parent `/processes`: **STD**.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path (declaration order) | Component\n\n| `''`\n| redirect to `list`\n\n| `list`\n| `ProcessListComponent`\n\n| `:processKey/batches`\n| `ProcessBatchesComponent`\n\n| `:processKey/new`\n| `ProcessAddComponent`\n\n| `:processKey/edit`\n| `ProcessEditComponent`\n\n| `:processKey/:paymentKey`\n| `SupplierPaymentsDetailsComponent`\n\n| `:supKey`\n| `PendingEditComponent`\n\n| `approval`\n| `BalanceApprovalComponent` — **unreachable**, shadowed by `:supKey`\n|===\n\nSecond table, parent `/proceses` (sic):\n`canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard]` — *no*\n`NgxPermissionsGuard`.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `''`\n| redirect to `approval`\n\n| `approval`\n| `BalanceApprovalComponent` — the reachable one; the sidebar links here\n|===\n\n[WARNING]\n====\nThe sidebar's *Payments* group links to `/processes/supplier-payments/batches`, which\nresolves through `:processKey/batches` with the literal string\n`supplier-payments` as `processKey`. See\nxref:domains:processes.adoc[processes].\n====\n\n== Reports\n\nParent `/reports`: **STD**.\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Path (declaration order) | Component | Notes\n\n| `''`\n| redirect to `list`\n|\n\n| `list`\n| `ReportListComponent`\n|\n\n| `external`\n| `ExternalReportListComponent`\n|\n\n| `new`\n| `ReportAddComponent`\n|\n\n| `waiting-suppliers`\n| `WaitingSuppliersReportComponent`\n|\n\n| `:reportKey/edit`\n| `ReportEditComponent`\n|\n\n| `:reportName`\n| `ReportTableComponent`\n| `data: { supplierKeyInQueryParamsNotNeeded: true }` — catch-all, must stay last\n|===\n\n== Underwriting tracker\n\nParent `/underwriting`: **STD**.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Path | Component\n\n| `''`\n| redirect to `tracker`\n\n| `tracker`\n| `TrackerComponent` (`pathMatch: 'full'`) — renders EVA\n|===\n\nVisibility is additionally gated by the `UNDERWRITING_TRACKER` flag *in the sidebar*,\nnot on the route.\n\n== Suppliers\n\nParent `/suppliers`: `canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard]` —\nno `NgxPermissionsGuard` at this level.\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Path | Component | Access\n\n| `''`\n| redirect to `../`\n|\n\n| `list`\n| `SupplierListComponent`\n|\n\n| `:supplierKey`\n| `SupplierComponent`, `resolve: { supplierKey: SupplierKeyResolver }`\n|\n|===\n\n=== Children of `:supplierKey`\n\n[cols=\"3,2,2\",options=\"header\"]\n|===\n| Path | Component | Access\n\n| `''`\n| redirect to `general`\n|\n\n| `general`\n| `SupplierGeneralComponent`\n| **AUD-CFG**\n\n| `additional-business-owners`\n| `AdditionalBusinessOwnersComponent`\n| —\n\n| `additional-business-owners/new`\n| `AdditionalBusinessOwnersComponent`\n| `data: { isNew: true }`\n\n| `summary` → `''` → `categorization`\n| `SupplierSummaryComponent` / `CategorizationComponent`\n| **UW-CFG**\n\n| `onboarding`\n| `SupplierOnboardingComponent`\n| **UW-CFG**\n\n| `new-underwriting`\n| `SupplierUnderwritingBetaComponent`\n| `canLoad`/`canActivate: [FeatureFlagGuard]`\n\n| `underwriting`\n| `SupplierUnderwritingComponent`\n| **RUW-CFG**\n\n| `underwriting/''`\n| redirect to `initial`\n|\n\n| `underwriting/initial`\n| `UnderwritingInitialComponent`\n| —\n\n| `underwriting/background-reports`\n| `BackgroundReportsComponent`\n| `NgxPermissionsGuard`, `{ only: FINANCE }`\n\n| `underwriting/daily`\n| `UnderwritingDailyComponent`\n| **UW-CFG**\n\n| `underwriting/supplier-information`\n| `SupplierInformationComponent`\n| —\n\n| `underwriting/documentation`\n| `DocumentationComponent`\n| `canLoad`/`canActivate: [FeatureFlagGuard]`\n\n| `underwriting/bank-transactions-report`\n| `UnderwritingReportTabComponent`\n| `data: { reportId: ExternalReports.PLAID }`\n\n| `underwriting/bank-transactions-categories`\n| `UnderwritingReportTabComponent`\n| `data: { reportId: ExternalReports.BANK_CATEGORIES }`\n\n| `underwriting/access-onboarding-predictions`\n| `UnderwritingReportTabComponent`\n| `data: { reportId: ExternalReports.ACCESS_ONBOARDING_PREDICTIONS }`\n\n| `underwriting/access-daily-predictions`\n| `UnderwritingReportTabComponent`\n| `data: { reportId: ExternalReports.ACCESS_DAILY_PREDICTIONS }`\n\n| `underwriting/ucc`\n| `UccMainComponent`\n| **UW-CFG**\n\n| `underwriting/ucc/''`\n| redirect to `list`\n|\n\n| `underwriting/ucc/list`\n| `UccListComponent`\n| —\n\n| `underwriting/ucc/create`\n| `UccCreateComponent`\n| —\n\n| `underwriting/ucc/edit/:id`\n| `UccCreateComponent`\n| —\n\n| `underwriting/ucc/transUnion/:id`\n| `UccTransUnionComponent`\n| —\n\n| `payment`\n| `SupplierPaymentComponent`\n| **UW-CFG**\n\n| `payment/''`\n| redirect to `list`\n|\n\n| `payment/list`\n| `SupplierPaymentListComponent`\n| —\n\n| `payment/new`\n| `OutgoingPaymentAddComponent`\n| own `NgxPermissionsGuard`, STD except-clause\n\n| `payment/edit/:paymentConfigKey`\n| `OutgoingPaymentAddComponent`\n| own `NgxPermissionsGuard`, STD except-clause\n\n| `payment/deposit-account-edit/:depositAccountKey`\n| `IncomingPaymentAddComponent`\n| own `NgxPermissionsGuard`, STD except-clause\n\n| `health`\n| `SupplierHealthComponent`\n| **UW-CFG**\n\n| `statements`\n| `SupplierMarketplaceStatementsComponent`\n| **MPS-CFG** — `reportName: 'mp_statements'`,\n  `reportConfigName: 'v_mp_statements'`,\n  `supplierKeyInQueryParamsNotNeeded: true`\n\n| `reports`\n| `SupplierReportsComponent`\n| **UW-CFG**\n\n| `reports/''`\n| redirect to `list`\n|\n\n| `reports/list`\n| `SupplierReportListComponent`\n| —\n\n| `reports/:reportName`\n| `SupplierReportDetailsComponent`\n| —\n\n| `fees`\n| `SupplierFeesComponent`\n| *no restriction config*\n\n| `fees/''`\n| redirect to `purchase`\n|\n\n| `fees/purchase`\n| `PurchaseFeesComponent`\n| —\n\n| `fees/payment`\n| `PaymentFeesComponent`\n| —\n\n| `fees/reserve`\n| `ReserveComponent`\n| —\n\n| `fees/reserve/edit/:marketplaceKey`\n| `EditMarketplaceComponent`\n| —\n\n| `marketplaces`\n| `SupplierMarketplacesComponent`\n| **UW-CFG**\n\n| `marketplaces/''`\n| redirect to `list`\n|\n\n| `marketplaces/list`\n| `SupplierMarketplaceListComponent`\n| —\n\n| `marketplaces/edit/:mpSupplierKey`\n| `SupplierMarketplaceEditComponent`\n| —\n\n| `transaction-summary`\n| `SupplierTransactionSummaryComponent`\n| **UW-CFG**\n\n| `transaction-details`\n| `SupplierTransactionDetailsComponent`\n| **UW-CFG**\n\n| `receivables`\n| `SupplierReceivablesComponent`\n| **UW-CFG**\n\n| `advances`\n| `SupplierAdvancesComponent`\n| **UW-CFG**\n\n| `advances/''`\n| `BaseAdvancesComponent`\n| —\n\n| `advances/new`\n| `NewOfferComponent`\n| —\n\n| `advances/advance/:advanceKey`\n| `ActiveAdvanceDetailsComponent`\n| —\n\n| `advances/advance-line/:advanceLineKey`\n| `ActiveAdvanceLineDetailsComponent`\n| —\n\n| `advances/pending-request/:requestKey`\n| `PendingOfferComponent`\n| —\n\n| `advances/ml-offer/:mlOfferKey`\n| `OfferEligibilityDetailsComponent`\n| —\n\n| `advances/progressive-details`\n| `InstantAdvanceUnderwritingComponent`\n| —\n\n| `advances/progressive-details/:advanceOfferRequestKey`\n| `InstantAdvanceUnderwritingComponent`\n| —\n\n| `amazon-lending`\n| `SupplierAmazonLendingComponent`\n| **UW-CFG**\n\n| `amazon-lending/''`\n| redirect to `list`\n|\n\n| `amazon-lending/list`\n| `SupplierAmazonLendingListComponent`\n| —\n\n| `amazon-lending/add-loan`\n| `SupplierAmazonLendingLoanComponent`\n| `SubPermissionsGuard`,\n  `{ requireOneOfSubPermissions: [MP_LOANS_MODIFY] }`\n\n| `amazon-lending/loan/:loanKey`\n| `SupplierAmazonLendingLoanComponent`\n| `SubPermissionsGuard`,\n  `{ requireOneOfSubPermissions: [MP_LOANS_DELETE, MP_LOANS_MODIFY] }`\n\n| `card-transactions/efs`\n| `SupplierEfsAccountComponent`\n| **UW-CFG**\n\n| `card-transactions/marqeta`\n| `SupplierMarqetaAccountComponent`\n| **UW-CFG**\n\n| `card-transactions/marqeta/:marqetaKey`\n| `CardTransactionsDetailsComponent`\n| —\n\n| `card-transactions/airwallex/disputes`\n| `AwxDisputesPageComponent`\n| **UW-CFG** — must stay above `:awxKey`, see <<ordering-hazards>>\n\n| `card-transactions/airwallex/:awxKey`\n| `AwxCardTransactionsDetailsComponent`\n| —\n\n| `card-transactions/airwallex`\n| `SupplierAwxAccountComponent`\n| **UW-CFG**\n\n| `contracts`\n| `SupplierContractsComponent`\n| **UW-CFG**\n|===\n\n== Route `data` keys in use\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Consumer\n\n| `permissions`\n| `NgxPermissionsGuard`\n\n| `enabledByFeatureFlag`\n| `FeatureFlagGuard`\n\n| `requireOneOfSubPermissions`\n| `SubPermissionsGuard`\n\n| `reportId`\n| `UnderwritingReportTabComponent`\n\n| `reportName`, `reportConfigName`\n| Report components\n\n| `supplierKeyInQueryParamsNotNeeded`\n| Report table components\n\n| `isNew`\n| `AdditionalBusinessOwnersComponent`\n|===\n\n[#ordering-hazards]\n== Ordering hazards\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Table | Hazard\n\n| `/processes`\n| `:supKey` precedes `approval`, shadowing it. `/proceses/approval` is the working\n  path.\n\n| `/reports`\n| `:reportName` is a catch-all and must stay last. A report configured with the name\n  `list`, `new`, `external` or `waiting-suppliers` would be unreachable.\n\n| `/suppliers`, Airwallex card transactions\n| `card-transactions/airwallex/disputes` must stay **above**\n  `card-transactions/airwallex/:awxKey`. The parameterised route matches first otherwise\n  and reads `disputes` as a transaction key, so the disputes page silently becomes the\n  transaction-details screen. Nothing fails loudly; the route table order *is* the\n  guarantee.\n\n| Root\n| `AppRoutesModule` must be imported **last** in `AppModule`; it owns `**` and the `''`\n  redirect.\n|===\n\n== Related\n\n* xref:architecture:routing.adoc[Routing and navigation] — the mechanics.\n* xref:permission-catalog.adoc[Permission catalogue] — what the clauses mean.\n* xref:feature-flag-catalog.adoc[Feature flag catalogue].\n* xref:domains:index.adoc[Business domains] — what each area does.\n"},{"component":"internal-admin","module":"reference","path":"reference:store-actions.adoc","title":"Store action catalogue","description":"Every NgRx slice, its action types, selectors and the effects class that handles them.","url":"https://docx.payability.com/internal-admin/reference/store-actions.html","navOrder":52,"source":"= Store action catalogue\n:description: Every NgRx slice, its action types, selectors and the effects class that handles them.\n\nTwelve root slices, declared in `{source-root}/store/app.reducers.ts`. Six effect\nclasses, registered in `EffectsModule.forRoot([...])` in\n`{source-root}/app.module.ts`.\n\nNgRx {ngrx-version} — actions are classes with a `readonly type` referencing an\nexported string constant. See\nxref:architecture:state-management.adoc[state management] for the idioms.\n\n== Slice summary\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| Slice | Reducer | Effects | Location\n\n| `auth`\n| `authReducers`\n| `AuthEffects`\n| `auth/store/`\n\n| `globalSettings`\n| `globalSettingsReducers`\n| `GlobalSettingsEffects`\n| `manage/global-settings/store/`\n\n| `layout`\n| `layoutReducer`\n| —\n| `shared/layout/store/`\n\n| `supplier`\n| `supplierReducers`\n| `SupplierEffects`\n| `suppliers/store/supplier/`\n\n| `crm`\n| `crmReducers`\n| `CrmEffects`\n| `crm-tools/store/`\n\n| `utils`\n| `utilsReducers`\n| `UtilsEffects`\n| `shared/utils/store/`\n\n| `calc`\n| `calculatorReducers`\n| —\n| `suppliers/supplier/supplier-advances/pending-offer/pending-offer-calculator/store/`\n\n| `marketplace`\n| `marketplacesReducer`\n| —\n| `marketplaces/store/`\n\n| `onboarding`\n| `onboardingReducer`\n| —\n| `manage/store/`\n\n| `errors`\n| `errorsReducer`\n| —\n| `shared/interceptors/`\n\n| `backgroundReports`\n| `backgroundReportsReducer`\n| *(empty file)*\n| `suppliers/store/background-reports/`\n\n| `underwriting`\n| `underwritingReducers`\n| `UnderwritingEffects`\n| `suppliers/supplier/supplier-underwriting/store/`\n|===\n\n== `auth`\n\nActions (`auth/store/auth.actions.ts`):\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Type | Handled by\n\n| `TRY_LOGIN`\n| `AuthEffects.authLogin` — Basic auth against `internalApiUrl auth/login`\n\n| `TRY_LOGIN_GOOGLE`\n| `AuthEffects.authLoginGoogle` — exchanges the Google credential\n\n| `LOGIN`\n| Reducer (persists to `localStorage['currentUser']`) +\n  `AuthEffects.login` (redirect, `dispatch: false`)\n\n| `LOGIN_FAILED`\n| Reducer (sets `authError`) + `AuthEffects.loginFailedCleanup`\n  (`dispatch: false`, clears storage)\n\n| `LOGOUT`\n| Reducer (reset) + `AuthEffects.authLogout` (`dispatch: false`,\n  `localStorage.clear()` and navigate to `/login`)\n\n| `CLEAR_AUTH_STATE`\n| Reducer (reset)\n\n| `HANDLE_AUTH_REDIRECT`\n| Declared; no reducer case or effect\n|===\n\nSelectors: `selectAuthToken`, `selectLoggedUserModel`,\n`selectLoggedUserLoginKey`, `isLoggedIn`. Helpers: `hasCorrectToken`,\n`hasCorrectData`.\n\nSee xref:platform:authentication.adoc[authentication].\n\n== `supplier`\n\n~70 action types — the largest slice. Grouped:\n\n=== Identity and loading\n\n`SET_SUPPLIER_KEY`, `SET_SUPPLIERS_QUERY_STRING`, `UPDATE_SUPPLIER_MODEL`,\n`UPDATE_SUPPLIER_DETAILS_MODEL`, `FETCH_SUPPLIER_DETAILS`,\n`FETCH_SUPPLIER_DETAILS_ERROR`, `UNLOAD_SUPPLIER`,\n`UPDATE_SUPPLIER_DETAILS_MODEL_BY_EVA`.\n\n=== Marketplaces\n\n`FETCH_SUPPLIER_MARKETPLACES`, `…_ERROR`,\n`FETCH_SUPPLIER_MARKETPLACE`, `…_SUCCESS`, `…_ERROR`,\n`FETCH_SUPPLIER_MARKETPLACE_DETAILS_PART`, `…_ERROR`,\n`ADD_PAYMENT_GATEWAY`.\n\n=== Partial editing\n\n`UPDATE_MODEL_PART`, `UPDATE_MODEL_PART_SUCCESS`,\n`UPDATE_MODEL_PART_ERROR`, `UPDATE_MODEL_PART_MAPS`.\n\n=== Payment configs\n\n`FETCH_SUPPLIER_PAYMENT_CONFIGS`, `…_ERROR`,\n`CREATE_SUPPLIER_PAYMENT_CONFIGS`, `…_SUCCESS`, `…_ERROR`,\n`UDPATE_SUPPLIER_PAYMENT_CONFIGS` (sic), `UPDATE_SUPPLIER_PAYMENT_CONFIGS_SUCCESS`,\n`…_ERROR`,\n`DELETE_SUPPLIER_PAYMENT_CONFIGS`, `…_SUCCESS`, `…_ERROR`,\n`PC_RESPONSE_SUCCESS`, `PC_RESPONSE_ERROR`, `PC_CLEAR_STATUS`.\n\n=== Lifecycle toggles\n\n`TOGGLE_HIDE_SUPPLIER`, `…_SUCCESS`, `…_ERROR`,\n`TOGGLE_SUSPEND_SUPPLIER`, `…_SUCCESS`, `…_ERROR`,\n`TOGGLE_LOCK_SUPPLIER`, `…_SUCCESS`, `…_ERROR` *(no reducer cases)*.\n\n=== Opt-in periods\n\n`ADD_OPT_IN_ROW`, `…_SUCCESS`, `…_ERROR`, `ADD_OPT_IN_ROW_CLEAR`,\n`DELETE_OPT_IN_ROW`, `…_SUCCESS`, `…_ERROR`.\n\n=== Compliance and cards\n\n`FETCH_COMPLIANCE_ACTIONS_WITH_FILES`, `…_ERROR`,\n`REQUEST_EFS_CARD`,\n`FETCH_CARD_PROVIDERS`, `…_SUCCESS`, `…_ERROR`,\n`FETCH_SUPPLIER_CARDS`, `…_SUCCESS`, `…_ERROR`,\n`FETCH_AIRWALLEX_STATUS`, `…_SUCCESS`, `…_ERROR`,\n`REGISTER_AIRWALLEX_CARDHOLDER`, `…_SUCCESS`, `…_ERROR`,\n`ISSUE_AIRWALLEX_CARD`, `…_SUCCESS`, `…_ERROR`.\n\n`IssueAirwallexCard` carries\n`{ supplierKey, formFactor, recipientAddress?, expediteCard? }` — the last two are the\nshipping details for a physical card. See xref:domains:cards.adoc[cards].\n\n`RegisterAirwallexCardholder` carries `{ supplierKey, loginKey, overwriteMissingDocuments? }`.\nThe last one asks the backend to submit the supplier without the compliance documents it\nhas not uploaded yet; the reducer records it in `airwallexOverrideRequested` so a failed\noverride is never offered again. See\nxref:domains:cards.adoc#awx-partial-submit[submitting with the minimum documents].\n\nBoth `…_ERROR` actions keep the backend message: `REGISTER_AIRWALLEX_CARDHOLDER_ERROR`\nstores it in `airwallexCardholderBlock` and `ISSUE_AIRWALLEX_CARD_ERROR` in\n`airwallexCardError`, both via `extractAirwallexError`. See\nxref:domains:cards.adoc#awx-errors[surfacing Airwallex failures].\n\n`airwallexCardholderBlock` is an `AirwallexSubmitBlock`, built by `resolveCardholderBlock`\nfrom the 400 body:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `message`\n| The verbatim backend message, via `extractAirwallexError`.\n\n| `overridable`\n| `true` only when the body carried `can_overwrite: true` *and* the failed request was not\n  already an override. Drives whether the panel offers a resubmit.\n\n| `missingDocuments`\n| The `missing_documents` codes the backend named, filtered to non-empty strings; `[]` when\n  it named none or sent a non-array.\n|===\n\n=== Selectors\n\n`selectSupplier`, `selectSupplierModel`, `selectLastQueryString`,\n`selectSupplierDetails`, `selectSupplierDetailsError`,\n`selectFundingParticulars`, `selectEfsCardRequested`, `selectPaymentMethods`,\n`selectSupplierBalance`, `selectSupplierDetailsAndFunding`,\n`selectModelPartSaved`, `selectModelPartErrors`,\n`selectSupplierMarketplaces`, `selectSupplierMarketplacesList`,\n`selectSupplierMarketplacesFundings`,\n`selectSupplierMarketplacesOptInPeriods`,\n`selectSupplierMarketplacesMinFeeOverrides`,\n`selectAddOptInProggress` (sic), `selectAddOptInError`,\n`selectCompleteKycDocuments`, `selectPendingPCStates`,\n`selectAvailableCardProviders`, `selectSupplierCards`,\n`selectAirwallexStatus`, `selectLoadingAirwallexStatus`,\n`selectRegisteringAirwallex`, `selectIssuingAirwallexCard`,\n`selectAirwallexCardError`, `selectAirwallexCardholderError`,\n`selectAirwallexCardholderBlock`.\n\n=== Effects\n\n`SupplierEffects` handles `ROUTER_NAVIGATION` (fanning out three actions), the\naggregate `forkJoin`, marketplaces, payment configs, compliance, lifecycle toggles,\ncard providers and the Airwallex flows.\n\nSee xref:domains:suppliers.adoc[suppliers] and xref:domains:cards.adoc[cards].\n\n== `crm`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Group | Types\n\n| Target\n| `SET_TARGET_UUID`\n\n| Model\n| `ADD_CRM_MODEL`, `UPDATE_CRM_MODEL`, `DELETE_CRM_MODEL`,\n  `CRM_CLEAR_CURRENT_STATE`\n\n| Fetch\n| `FETCH_CRM_ALL`, `FETCH_CRM_NOTES`, `FETCH_CRM_FILES`, `FETCH_CRM_TASKS`,\n  `FETCH_CRM_EVENTS`, `FETCH_CRM_EMAILS`, `FETCH_CRM_BATCH_EMAILS`,\n  `FETCH_CRM_PINNED_NOTES`, `FETCH_CRM_DATA_ERROR`\n\n| Item status\n| `CRM_ITEM_REQUEST`, `CRM_ITEM_RESPONSE_SUCCESS`,\n  `CRM_ITEM_RESPONSE_ERROR`, `CRM_ITEM_CLEAR_STATUS`\n\n| Notes\n| `ADD_CRM_NOTE`, `EDIT_CRM_NOTE`, `PIN_CRM_NOTE`, `DELETE_CRM_NOTE`\n\n| Tasks\n| `ADD_CRM_TASK`, `EDIT_CRM_TASK`, `DELETE_CRM_TASK`,\n  `CRM_BEFORE_TASK_SAVE`, `CRM_AFTER_TASK_SAVE`\n\n| Files\n| `ADD_CRM_FILE`, `DELETE_CRM_FILE`\n\n| Email\n| `SEND_SINGLE_EMAIL`\n\n| Sentinel\n| `NO_ACTION`\n|===\n\nSelectors: `selectCrmTargetUuid`, `selectCrmStateModel`, `selectCrmItems`,\n`selectPendingCrmStates`, plus one per stream generated by\n`createTimelineStreamSelector` — `selectCrmAll`, `selectCrmNotes`,\n`selectCrmFiles`, `selectCrmTasks`, `selectCrmEvents`, `selectCrmEmails`,\n`selectCrmBatchEmails`, `selectCrmPinnedNotes`.\n\n`CrmEffects` also broadcasts `TASK_COUNTERS_UPDATE` and `CRM_OPEN_ALL_TAB` on the\n`TableEventsService` bus. See xref:domains:crm.adoc[CRM].\n\n== `calc` (advance calculator)\n\nA *map-of-handlers* reducer rather than a `switch` — add an action with\n`calculatorMap.set(TYPE, handler)`.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Group | Types\n\n| Amounts\n| `SET_APPROVED_AMOUNT`, `SET_NET_DELIVERY_AMOUNT`,\n  `SET_REQUESTED_AMOUNT`, `SET_RESERVE_FOR_REPAYMENTS`\n\n| Periods\n| `SET_FEE`, `SET_PERIOD_LENGTH`, `ADD_PERIOD`, `REMOVE_PERIOD`\n\n| Risk / defaults\n| `SET_UNDERWRITING_RISK_RATING`,\n  `SET_DEFAULT_PROGRESSIVE_ADVANCE_SETTINGS`, `SET_ELIGIBILITY_DATA`\n\n| Notes\n| `SET_REQUEST_NOTES`, `SET_UNDERWRITING_NOTES`\n\n| Terms\n| `SET_GRACE_PERIOD_END_DATE`, `SET_GRACE_PERIOD_SAVED_END_DATE`,\n  `SET_NO_REBATE`, `SET_NO_REBATE_SAVED`,\n  `SET_RETURN_STRATEGY`, `SET_RETURN_STRATEGY_SAVED`,\n  `SET_FORCE_FULL`, `SET_FORCE_FULL_SAVED`,\n  `SET_EXPECTED_MONTHLY_EARNINGS`, `SET_EXPECTED_DURATION`\n\n| Mode\n| `SET_TO_READONLY`, `SET_TO_EDITABLE`, `SET_NEW_OFFER`, `SET_UW_STATUS`\n\n| Lifecycle\n| `SET_STATE_FROM_PERSISTED_DATA`, `CALCULATOR_RESET`,\n  `CALCULATOR_HARD_RESET` (preserves `defaultProgressiveAdvanceSettings`)\n|===\n\nBest-tested slice in the repository (`calculator.spec.ts`). See\nxref:domains:advances.adoc[advances].\n\n== `layout`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Group | Types\n\n| Route\n| `LAYOUT_UPDATE` — dispatched by `AppComponent` on `NavigationEnd`\n\n| CRM panel\n| `CRM_PANEL_OPEN`, `CRM_PANEL_CLOSE`, `CRM_PANEL_FIX`, `CRM_PANEL_HIDE`\n\n| Supplier info panel\n| `SUPPLIER_INFO_OPEN`, `SUPPLIER_INFO_CLOSE`, `SUPPLIER_INFO_HIDE`\n\n| Chrome\n| `SEARCH_BUTTON_SHOW`, `SEARCH_BUTTON_HIDE`, `ACTIONS_SHOW`, `ACTIONS_HIDE`\n\n| Search engine\n| `CHOOSE_NEW_SEARCH`, `CHOOSE_OLD_SEARCH`\n|===\n\nReducer-only, with route-pattern defaults and `localStorage['layoutState']`\npersistence. See xref:architecture:ui-composition.adoc[UI composition].\n\n== `utils`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Type | Behaviour\n\n| `FETCH_UTIL_ATTR`\n| `UtilsEffects.fetchUtilAttribute` — dispatches only if not already loaded, or when\n  `payload.refresh` is set\n\n| `SET_UTIL_ATTR`\n| Reducer writes the attribute and persists the whole slice via `lscache` (24 h)\n|===\n\nAttribute → service-method map in the effect: `timezones` → `getTimezones`,\n`fundingPeriods` → `getFundingPeriods`, `usStates` → `getUSStates`,\n`businessTypes` → `getUSStates` *(appears to be a copy-paste bug)*,\n`countries` → `getCountries`.\n\nSelectors generated per attribute: `selectTimezones`, `selectFundingPeriods`,\n`selectUsStates`, `selectCountries`.\n\n== `globalSettings`\n\nUses a *prefixed enum* rather than bare constants:\n\n[source,typescript]\n----\nexport enum GLOBAL_SETTINGS_ACTION {\n  FETCH_ALL         = '[GLOBAL SETTINGS] Fetch All',\n  FETCH_ALL_SUCCESS = '[GLOBAL SETTINGS] Fetch All Success',\n  UPDATE_CONFIG     = '[GLOBAL SETTINGS] Update Config',\n}\n----\n\nSelectors: `selectConfigs`, `selectLoading` (in a dedicated\n`global-settings.selectors.ts`). `GlobalSettingsService` is a facade over this slice —\ncall the service, not the slice. See xref:domains:manage.adoc[manage].\n\n== `underwriting`\n\n[source,typescript]\n----\nexport enum UNDERWRITING_ACTION {\n  FETCH_TOOLS         = '[UNDERWRITING] Fetch Tools',\n  FETCH_TOOLS_SUCCESS = '[UNDERWRITING] Fetch Tools Success',\n  FETCH_TOOLS_FAILED  = '[UNDERWRITING] Fetch Tools Failed',\n}\n----\n\nState: `{ toolsLoaded: boolean, tools: UwToolEntity[] }`. Selector: `selectUwTools`\n(returns `null` unless loaded *and* non-empty). Handled by `UnderwritingEffects`. See\nxref:domains:supplier-underwriting.adoc[underwriting].\n\n== `marketplace`\n\nSingle action `SET_POSSIBLE_GATEWAYS`; state\n`{ possiblePaymentGateways: IPaymentGateway[] }`. Map-of-handlers reducer, no effects.\nSee xref:domains:marketplaces.adoc[marketplaces].\n\n== `onboarding`\n\nSingle action `RESET_ALL_FORMS`. Map-of-handlers reducer, no effects. See\nxref:domains:supplier-onboarding.adoc[onboarding].\n\n== `backgroundReports`\n\nSingle action `SET_SUPPLIER_BACKGROUND_REPORT`. Reducer registered; the effects file\nexists but is **empty**. See\nxref:domains:supplier-underwriting.adoc#background[background reports].\n\n== `errors` (fetched requests)\n\nSingle action `FETCH_REQUEST`, dispatched by `FetchedRequestsInterceptor` on every\nHTTP request. State `{ cachedRequests: any[] }`, capped at\n`REQUESTS_AMOUNT = 50`, serialised with `JSON.stringify`. Debugging aid; visible in\nRedux DevTools and included in `ErrorLogger`'s store dump. See\nxref:platform:error-handling.adoc[error handling].\n\n== Naming conventions\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Convention | Where\n\n| Bare `SCREAMING_SNAKE` constants\n| Older slices — `auth`, `supplier`, `crm`, `layout`, `utils`, `calc`,\n  `marketplace`, `onboarding`, `errors`, `backgroundReports`.\n\n| `'[FEATURE] Title Case'` enum members\n| Newer slices — `globalSettings`, `underwriting`.\n\n| `X` / `X_SUCCESS` / `X_ERROR`\n| The base type is the *request*, not the result.\n\n| Selectors in the actions file\n| Older slices.\n\n| A dedicated `*.selectors.ts`\n| `globalSettings`, `underwriting`.\n|===\n\nKnown misspellings that are load-bearing (they are the wire/constant values):\n`UDPATE_SUPPLIER_PAYMENT_CONFIGS`, `selectAddOptInProggress`.\n\n== Router integration\n\n`StoreRouterConnectingModule` emits `ROUTER_NAVIGATION`. One consumer:\n`SupplierEffects.navigateToSupplier`, which walks the activated-route tree for a\n`supplierKey` parameter and dispatches `SET_SUPPLIER_KEY`, `SET_TARGET_UUID` and\n`FETCH_SUPPLIER_DETAILS`.\n\nA meta-reducer, `storeDebugger`, is registered in `StoreModule.forRoot` but disabled by\na source-level constant.\n\n== Mock reducers\n\nFor specs: `auth-mock.reducers.ts`, `crm-mock.reducers.ts`,\n`supplier-mock.reducers.ts`, `utils-mock.reducets.ts` (sic),\n`marketplace.reducer.mock.ts`. See xref:ops:testing.adoc[testing].\n"},{"component":"internal-admin","module":"reference","path":"reference:services-and-endpoints.adoc","title":"Services and API endpoints","description":"Every service, the base URL it uses, and the endpoints it calls.","url":"https://docx.payability.com/internal-admin/reference/services-and-endpoints.html","navOrder":53,"source":"= Services and API endpoints\n:description: Every service, the base URL it uses, and the endpoints it calls.\n\n~140 `@Injectable` services. Grouped by base URL below; endpoint paths are relative to\nthat base. Base-URL values per environment:\nxref:environment-variables.adoc[environment configuration keys]. Conventions:\nxref:architecture:http-and-services.adoc[HTTP layer and services].\n\nAll requests carry `X-Session-Token` via `AuthInterceptor`. Additional headers are\nnoted where they exist.\n\n== `internalApiUrl` — the primary API\n\n=== Suppliers\n\n`suppliers/services/supplier.service.ts` (`SupplierService`, + mock)\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Endpoint | Purpose\n\n| `GET suppliers`\n| List/search (`HttpParams`; `search2` / `search3` for the overlay).\n\n| `GET suppliers/:key?detailed=1&taxId=1[&refreshTimestamp=…]`\n| Supplier details.\n\n| `PUT suppliers/:key`\n| Save details.\n\n| `GET suppliers/:key/get_token`\n| Supplier auth token (login-as-supplier).\n\n| `GET suppliers/:key/amazon_health`\n| Account-health snapshot.\n\n| `GET suppliers/:key/supplier_advances/:advanceKey`\n| One advance.\n\n| `GET suppliers/:key/get_statements_for_add_supplier_fund_recovery/:mpSupplierKey`\n| Statements for a fund-recovery entry.\n\n| `GET reports/:reportName`\n| Run a report (`make_dump` switches to `responseType: 'text'`).\n|===\n\nAlso exposes `getSupplierBalance`, `saveItemPart` (partial update), and the constants\n`SEARCH_DEBOUNCE_TIME = 800`, `supplierKeyColumnName = 'supplier_key'`,\n`supplierDepositTransactionColName = 'supplier_deposit_transactions'`.\n\n=== Supplier lifecycle\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `cancel-supplier.service.ts`\n| `GET active_marketplaces/:key`, `GET suppliers/:key?taxId=1`,\n  `GET cancellation_reasons`,\n  `PUT suppliers/:key/acc_cancellation`,\n  `GET suppliers/:key/get_acc_cancellation_reason`,\n  `POST suppliers/:key/change_acc_cancellation_reason`\n\n| `winback.service.ts`\n| `GET winback/reasons`\n\n| `delete-supplier-account.service.ts`\n| `GET`/`POST supplier/:key/remove_supplier_account` *(singular `supplier`)*\n\n| `request-collection.service.ts`\n| `GET suppliers/:key/collections_reasons`;\n  `GET`/`POST`/`PATCH`/`DELETE suppliers/:key/collections`\n\n| `supplier-statuses.service.ts`\n| No HTTP — status→badge-class dictionaries.\n|===\n\n=== Payments and accounts\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `payment-config/payment-config.service.ts` (+ mock)\n| `GET`/`POST suppliers/:key/payment/configs`;\n  `GET`/`PUT`/`DELETE …/configs/:paymentConfigKey`;\n  `GET`/`PUT suppliers/:key/velocity/limits`;\n  `POST suppliers/:key/jit/simulate/{pending,rejected,charge,cleared,refund}`\n\n| `deposit-account/deposit-account.service.ts` (+ mock)\n| `GET`/`POST`/`PUT deposit_accounts[/:depositAccountKey]`, plus a second\n  `POST deposit_accounts/:key`. Also uses `documentsApiUrl`.\n\n| `auto-payment.service.ts`\n| `POST suppliers/:key/payment/payment_features`\n\n| `supplier-payment/outgoing-payment-add/payment-history.service.ts`\n| Payment history for a config.\n\n| `…/supplier-payment-plaid-information/supplier-plaid.service.ts` (+ mock)\n| Plaid-derived bank information.\n|===\n\n=== Fees, funding and contracts\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `supplier-fees/service/supplier-fees.service.ts`\n| `GET`/`PUT suppliers/:key/funding_particulars`;\n  `GET`/`PUT suppliers/:key/funding_particulars/:marketplaceKey`;\n  `PUT suppliers/:key` (`{ loadNetEarnings }`)\n\n| `funding-particulars.service.ts` (+ mock)\n| Supplier funding record.\n\n| `supplier-marketplace-funding.service.ts`\n| `GET`/`PUT suppliers/:key/funding_particulars/:mpSupKey`\n\n| `supplier-marketplace-min-fee-overrides.service.ts`\n| `GET`/`PUT suppliers/:key/funding_particulars/:mpSupKey/min_fee_overrides`\n\n| `supplier-marketplace-opt-in-periods.service.ts`\n| `GET`/`PUT suppliers/:key/funding_particulars/:mpSupKey/opt_in_periods`;\n  `PUT …/opt_in_periods/:mpSupOptInPeriodKey`\n\n| `manage/fee-settings/service/fee-settings.service.ts`\n| `GET fee_schedule_types`; `POST fee_schedules`;\n  `PUT fee_schedules/:key`; `PUT fee_schedule_tier/:key`;\n  `PUT fee_schedules/:key/extra`; `DELETE fee_schedule_remove/:key`\n\n| `shared/services/long-term-contracts/long-term-contracts.service.ts`\n  (+ abstract + mock)\n| `GET long_term_contract/template[/:marketplaceKey/standard \\| /:templateGroupKey]`;\n  `POST long_term_contract/template/:marketplaceKey/create`;\n  `GET long_term_contract/:supplierKey[/history]`;\n  `GET long_term_contract/:contractKey`;\n  `POST long_term_contract/:mpSupplierKey/create`;\n  `GET long_term_contract/:mpSupplierKey/active`\n|===\n\n=== Advances\n\n`supplier-advances/service/supplier-advances.service.ts` — mostly under\n`supplier_instant_advance/`:\n\n[source]\n----\nPOST  supplier_instant_advance/:key/create_request\nGET   supplier_instant_advance/:key/{active_advance,advance_liability,advance_request}\nGET   supplier_instant_advance/:key/{ml_offer,ml_offer_details,re_offer_eligibility,requests_history}\nGET   supplier_instant_advance/offer_details\nPOST  supplier_instant_advance/:key/re_offer\nPOST  supplier_instant_advance/{update_uw_request,update_offer_request}\nPOST  supplier_instant_advance/:key/manually_update_request_uw_data/:advanceOfferRequestKey\nGET   supplier_instant_advance/:key/advance_line_history\nGET   supplier_instant_advance/advance_line/{:key/last,:advanceLineKey}\nPOST  supplier_instant_advance/:proposedAdvanceLineKey/retract\nGET   suppliers/:key/supplier_advances\nGET   suppliers/:key/advance_line/:advanceLineKey/uw_history\nPATCH suppliers/:key/advance_line/:advanceLineKey/update\n----\n\n=== Underwriting and compliance\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `supplier-underwriting/supplier-underwriting.service.ts`\n| `GET`/`PUT suppliers/:key/amazon_health_rules`;\n  `GET suppliers/:key/compliance_status[/:complianceKey/:action/:version]`;\n  `GET suppliers/:key/compliance_snapshot/list`, `…/:itemKey`;\n  `POST compliance_action_status/:version`;\n  `PUT compliance_action_status/:statusKey/:version`;\n  `PUT compliance_status/:statusKey`;\n  `GET suppliers/:key/check_amazon_api`;\n  `GET suppliers/:key/identity_check/{sole,company}/:partial`;\n  `GET`/`PUT suppliers/:key/marqeta_kyc_compliances[/owners]`;\n  `GET suppliers/:key/marketplace/:marketplaceKey/data`.\n  **Also uses `automaticMarketplaceApiUrl`.**\n\n| `ucc-main/ucc.service.ts`\n| `GET`/`POST suppliers/:key/ucc/filing`;\n  `GET`/`PUT`/`DELETE suppliers/:key/ucc/filing/:uccKey`;\n  `GET suppliers/:key/ucc/data[/:uccKey]`\n\n| `supplier-compliance-action.service.ts`,\n  `supplier-compliance-action-status.service.ts`\n| Compliance action records.\n\n| `background-reports/background-reports.service.ts`\n| Background reports (requires `FINANCE`).\n\n| `documentation/documentation.service.ts`\n| Underwriting documents.\n\n| `underwriting-initial/common/services/underwriting-initial.service.ts`\n| Initial-underwriting persistence.\n|===\n\n=== Onboarding\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `supplier-onboarding/supplier-onboarding.service.ts` (+ mock)\n| `suppliers/…` compliance and onboarding endpoints. **Also uses\n  `supWidgetsApiUrl`.** Holds the hardcoded compliance-action and marketplace UUIDs.\n\n| `supplier-verify-document-dialog/supplier-verify-document.service.ts`\n| Document verification.\n\n| `services-product-matrix/service-table/service/product-matrix.service.ts` (+ mock),\n  `flex-participation.service.ts` (+ mock),\n  `product-matrix-initialize-tables.service.ts`\n| The services/product matrix.\n\n| `shared/onboarding/service/onboarding.service.ts` (+ mock)\n| Onboarding templates.\n\n| `additional-business-owners/additional-businsess-owner.service.ts` *(sic)*\n| Additional owners.\n\n| `supplier-2fa/supplier-2fa.service.ts`\n| Supplier 2FA (flag `TWO_FA`).\n|===\n\n=== Cards\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `card-management/card-management.service.ts`\n  (`providedIn: 'root'`)\n| `GET suppliers/:key/card_providers`;\n  `GET suppliers/:key/cards` *(provider filter sent as a **GET body**)*;\n  `POST suppliers/:key/cards/{newcard,block/:cardId,activate/:cardId,remove/:cardId,reissue/:cardId}`;\n  `GET suppliers/:key/airwallex`;\n  `PUT suppliers/:key/airwallex/cardholder`;\n  `POST suppliers/:key/airwallex/newcard`;\n  `POST suppliers/:key/airwallex/cards/:cardId/refresh`;\n  `GET`/`PUT suppliers/:key/cards/:cardId/velocity/limits/awx`\n\n| `efs.service.ts` (+ mock)\n| `GET`/`POST suppliers/:key/efs/:resource`;\n  `GET`/`POST suppliers/:key/pcard/:resource`;\n  `GET suppliers/:key/efs/get_company_report`;\n  `GET suppliers/:key/pcard/get_general_report_v2`\n\n| `supplier-marqeta-account/supplier-marqeta.service.ts`\n| `GET suppliers/:key/pcard/get_general_report_v2[/statuses]`;\n  `GET suppliers/:key/pcard/:transactionId/transaction_details`\n\n| `supplier-awx-account/supplier-awx.service.ts`\n| `POST suppliers/:key/pcard/get_general_report_awx` *(also the CSV export, with\n  `responseType: 'text'`)*;\n  `GET suppliers/:key/pcard/get_general_report_awx/statuses`;\n  `GET suppliers/:key/pcard/:transactionId/transaction_details_awx`\n\n| `awx-disputes/awx-disputes.service.ts`\n  (`providedIn: 'root'`)\n| `GET`/`POST suppliers/:key/airwallex/disputes`;\n  `POST suppliers/:key/airwallex/disputes/:disputeId/{evidence,submit,cancel}`.\n  *Evidence is `multipart/form-data`, one file per request, with no `Content-Type` header\n  so the browser sets the boundary. Errors are neither logged nor wrapped — the backend\n  forwards the Airwallex message and callers render it verbatim.* See\n  xref:domains:cards.adoc#awx-disputes[disputes].\n|===\n\n=== Marketplaces\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `marketplaces/service/marketplace-repository.ts` (+ mock, abstract)\n| `GET`/`POST marketplaces`; `GET`/`PUT marketplaces/:uuid`;\n  `GET marketplace_period_groups`; `GET free_trials`.\n  *Imports `environment` directly.*\n\n| `marketplaces/service/gateway.service.ts`\n| `GET payment_gateways`;\n  `POST supplier/:mpSupplierKey/create_and_connect/:gatewayKey`;\n  `POST supplier/:mpSupplierKey/disconnect/:gatewayKey`.\n  *Imports `environment` directly.*\n\n| `suppliers/services/supplier-marketplaces.service.ts`\n| `GET`/`PUT suppliers/:key/mp/:mpSupKey`;\n  `PUT suppliers/:key/marketplace_suspend_reason`;\n  `POST supplier/:mpKey/update_payment_gateway_bank_account_number`.\n  **Also uses `marketplaceApiUrl`.**\n\n| `suppliers/services/marketplace-loans.service.ts` (+ mock)\n| Amazon-lending loans.\n|===\n\n=== Processes and approval\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `processes/service/payment-batch-repository-service.ts` (abstract)\n| `GET`/`POST paymentBatches`; `GET`/`PUT paymentBatches/:batchId`;\n  `POST paymentBatches/:batchId/{action,unlock}`;\n  `GET paymentBatches/:batchId/{status,payments,get_debit_payments}`;\n  `PUT`/`DELETE paymentBatches/:batchId/payments/:paymentId`.\n  *Serialises with `SerializeKeysTo(CamelCase)`; toasts on every error.*\n\n| `processes/service/payment-batch-downloader.service.ts` (+ abstract + mock)\n| `GET paymentBatches/:batchId/paymentFile?withAch=true` (+ cache-busting timestamp)\n\n| `processes/balance-approval/balance-approval.service.ts`\n| `GET pending_review/`; `GET pending_review/statistic`;\n  `PUT pending_review/:supplierKey`;\n  `GET lookup/codes/payment.approval.outcomeReason`\n\n| `processes/service/pending-review.service.ts`\n| `GET pending_review/`; `PUT /pending_review/:supplierKey` *(double slash)*.\n  *Imports `environment` directly.*\n|===\n\n=== Reports\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `reports/service/reports-repository.service.ts` (abstract)\n| `GET reports/config[/:uuid]`; `POST reports/config`;\n  `PUT`/`DELETE reports/config/:uuid`;\n  `POST reports/config/:uuid/overrides`;\n  `GET reports/db_objects`; `GET`/`PUT reports/:uuid`\n\n| `reports/service/external-reports.service.ts`\n| `GET reports/external_list`; `GET reports/external/:reportKeys`\n|===\n\n=== Users, permissions, lookups\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `shared/users/service/users.service.ts` (abstract + mock)\n| `GET logins?permCode=…[&search=…]`;\n  `GET`/`PUT`/`DELETE logins/:loginKey`;\n  `GET logins/:loginKey/perms`;\n  `GET perms`, `GET perms/:permCode/sub-perms`;\n  grant/revoke via `addPermissionToLogin` / `removePermissionFromLogin`.\n  *`saveLogin` rewrites `firstname`/`lastname` to `firstName`/`lastName`.*\n\n| `shared/lookup-service/lookup-repository.ts` (abstract + mock)\n| `GET timezones`, `funding_periods`, `countries`,\n  `countries/US/business_types`, `countries/:code/states`, `xactTypes`;\n  plus `getAuthGoogleLogin` for the SSO exchange.\n  *Imports `environment` directly.*\n\n| `shared/version.service.ts`\n| `GET version`. *Imports `environment` directly.*\n|===\n\n=== Other `internalApiUrl` consumers\n\n`supplier-email-preferences.service.ts`\n(`GET`/`PUT suppliers/:key/email_preference`),\n`supplier-referral.service.ts` (uses `supplierReferralUrl` for the link base),\n`shared/pyb-documents/pyb-documents.service.ts` (also `pybDocumentsApiUrl`),\n`crm-tools/services/action-tab-calculator.service.ts`,\n`crm-tools/services/emails.service.ts` (also `messagingUrl`),\n`shared/search-overlay/search-engine-service-3-0/search-engine.service.ts`\n(`GET suppliers?search3=…`),\n`manage/batch-imports/service/batch-imports.service.ts`,\n`shared/layout/app-stage-info/app-stage-info.component.ts`.\n\n== `cliApiUrl`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `suppliers/services/cli/cli.service.ts` (+ mock)\n| CLI operations.\n\n| `manage/batch-imports/cli-action/cli-action-service/cli-action.service.ts` (+ mock)\n| CLI action runner.\n\n| `manage/batch-imports/service/batch-imports.service.ts`\n| `POST efs_csv_upload`; `POST purchase_rcv` *(empty body — triggers a job)*.\n\n| `suppliers/services/supplier.service.ts`\n| Holds a `cliApiUrl` reference.\n|===\n\n== `actionsToolsUrl` — CRM and settings\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `crm-tools/services/crm.service.ts` (+ mock)\n| `GET activity/`, `GET notes/`, `GET files/` (via `listActionsBase`), plus note\n  CRUD. Headers: `Authorization` (raw JWT), `X-InstanceId`,\n  `X-EventLogger-InstanceId`, `X-Actions-TargetUUID` (kept in sync by a store\n  subscription).\n\n| `crm-tools/services/tasks.service.ts` (abstract + mock)\n| `GET`/`POST tasks/`; `GET`/`PUT`/`DELETE tasks/:uuid/`;\n  `DELETE tasks/` with `{ uuids }`; `PATCH tasks/`;\n  `GET tasks/:uuid/events/`\n\n| `crm-tools/services/tasks-filters.service.ts` (+ mock)\n| `GET tasks/filters/counts/`, `…/counts/assignees/`\n\n| `crm-tools/services/comments.service.ts` (+ mock)\n| Comment CRUD.\n\n| `manage/global-settings/service/global-settings.service.ts`\n| `GET settings/`, `GET settings/:configKey/`,\n  `GET settings/:type/items`, `POST settings/`,\n  `PUT`/`DELETE settings/:configKey/`. Header `x-instanceid`.\n\n| `manage/batch-imports/service/batch-imports.service.ts`\n| `POST notes/bulk-update/`\n\n| `reports/waiting-suppliers/waiting-suppliers.component.ts`\n| Calls this base **directly from a component** — the only such case.\n|===\n\n== `eventLoggerUrl`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `crm-tools/services/crm.service.ts`\n| `GET es-changelog` (`limit=2000000`), header\n  `X-EventLogger-TargetUUID`.\n\n| `shared/monitor.service.ts`\n| `POST track` with a **hardcoded** `Authorization: Token …`,\n  `X-EventLogger-InstanceId: 8003`, `X-EventLogger-Type: analitics`.\n|===\n\n== `eventRulesOldUrl`\n\n`manage/event-rules/events.service.ts` (+ `events-mock.service.ts.ts`, sic):\n\n[source]\n----\nPOST   event_rules/\nGET    event_rules/            GET event_rules/:uuid/\nPUT    event_rules/:uuid/      DELETE event_rules/:uuid/\nGET    event_rules/history/?limit&order_by&q[&last]\n(es)   Elasticsearch client against `<base>es`, index `em_events`\n----\n\nHeader `X-InstanceId: '8001'` (hardcoded literal). *Imports `environment`\ndirectly.*\n\n== `messagingUrl`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Endpoints\n\n| `crm-tools/services/emails.service.ts` (+ mock)\n| `GET email/`; `POST emails/`; `GET`/`PUT`/`DELETE emails/:uuid/`;\n  `POST emails/batch-delete/`; `GET emails/:uuid/events/`;\n  `POST email/rendered/`, `POST email/preview/`, `POST email/batch/`;\n  `GET email/{approve,cancel}/:emailUuid`;\n  `GET email/{batch_approve,batch_cancel}/:batchUuid`.\n  **The approve/cancel calls are `GET` requests that mutate state.**\n\n| `crm-tools/services/crm.service.ts`\n| Email-related reads.\n\n| `…/smsnumbers/sms-numbers-provider.service.ts` (+ mock)\n| SMS sending. **Also uses `authToolsApiUrl`.**\n|===\n\n== `authToolsApiUrl`\n\n`suppliers/services/supplier-auth-token.service.ts` — supplier auth tokens;\n`…/smsnumbers/sms-numbers-provider.service.ts` — SMS number provisioning.\n\n== `workflowApiUrl` (+ `workflowGciId`)\n\n`suppliers/services/workflow/b2b-workflow.service.ts`:\n\n[source]\n----\nSSE  tasksMonitoring\nGET  getLastExecution?gci=<gci>[&name=…]\nGET  getListOfActivities?id=<encoded>&gci=<gci>\nPOST executeWorkflow?gci=<gci>     Content-Type: application/x-www-form-urlencoded\n----\n\nPlus `downloadFile` / `downloadPdfFile` (blob via `file-saver`). See\nxref:platform:realtime.adoc[real-time streams].\n\n== `automaticMarketplaceApiUrl`\n\n`supplier-underwriting/supplier-underwriting.service.ts` and\n`…/real-time-validation/real-time-validator.service.ts` (+ mock).\n\n== `marketplaceApiUrl`\n\n`suppliers/services/supplier-marketplaces.service.ts` —\n`POST /platforms/:marketplaceKey/update_sales_data` (note the double slash from the\nleading `/`).\n\n== `supWidgetsApiUrl`\n\n`supplier-onboarding/supplier-onboarding.service.ts`.\n\n== Document backends\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Base URL | Service | Notes\n\n| `b2bDocumentsApiUrl`\n| `shared/b2b-document/b2b-document.service.ts` (+ mock),\n  `batch-imports.service.ts`\n| B2B documents.\n\n| `pybDocumentsApiUrl`\n| `shared/pyb-documents/pyb-documents.service.ts`\n| Behind the `PYB_DOCUMENTS` flag. Also probed by\n  `VpnTesterService` (`OPTIONS health-check`).\n\n| `documentsApiUrl`\n| `suppliers/services/deposit-account/deposit-account.service.ts`\n| Deposit-account documents.\n\n| `s3FilesProviderApi`\n| Configured; used by file-download paths.\n| —\n\n| —\n| `shared/apps-document/apps-document.service.ts`\n| Routes between backends based on `PYB_DOCUMENTS`.\n|===\n\n== `evaApiUrl`, `supplierUrl`, `supplierReferralUrl`\n\nNot HTTP APIs from this app's perspective:\n\n* `shared/eva/eva.service.ts` — builds the iframe URL and the `PYBEvaToken` cookie.\n* `suppliers/services/login-as-supplier/login-as-supplier.service.ts` — navigates to\n  the supplier site.\n* `suppliers/services/supplier-referral.service.ts` — builds referral links.\n\n== Services with no HTTP\n\n`SupplierStatusesService` (badge dictionaries), `MessageService` (canned messages),\n`TableEventsService` (bus), `CancelEmitService` (bus),\n`CardFormgroupMessageService`, `CsvExportService`, `DialogService`,\n`ErrorParserService`, `ErrorVisualizationService`, `Logger`,\n`LayoutService`, `PeriodsCalculationsService`,\n`ProductMatrixInitializeTablesService`, `EmailsStatusService`,\n`RulesUtilsService`, `AgGreedDataReshaperService`,\n`DataTableAgGridHeaderTemplateRendererService`,\n`HeadersWithSummedValuesSettingService`, `FilteringPanelService`,\n`DynamicFormControlService`, `ElasticHighlightsService`,\n`ResizeIframeService`, `CommunicationService`, `EvaCommunicationService`,\n`FeatureFlagsService` / `-Admin` / `-Eva`, `HttpCacheLocalStorage`,\n`DynamicEmbedderService`, `GameService` (Tetris),\n`ActionTabCalculatorService` *(HTTP)*, `CookieService`, `SseService`.\n\n== Response envelopes\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Shape | Where\n\n| Bare array/object\n| Internal API lookups.\n\n| `{ results, total }`\n| Internal API list endpoints — `logins`, `paymentBatches`, `pending_review`.\n\n| `{ data, meta }`\n| Actions Tools, event rules, global settings.\n\n| Elasticsearch hits\n| `search3`, `es-changelog`, `em_events`.\n|===\n\n== Conventions and hazards\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Item | Detail\n\n| Base URLs\n| Always from `AppConfigService`. Eleven services still import `environment`\n  directly — legacy.\n\n| Hardcoded credential\n| `MonitorService`'s `Authorization: Token …`.\n\n| `GET` with a body\n| `CardManagementService.getCards`, `TasksService` bulk delete.\n\n| `GET` that mutates\n| `EmailsService` approve/cancel (single and batch).\n\n| Global serialisation casing\n| `SerializeKeysTo` / `DeserializeKeysFrom` are set per call and are module-global.\n\n| Double slashes\n| `PendingReviewService.updatePendingReview`,\n  `SupplierMarketplacesService.updateSalesData`.\n\n| Singular `supplier/`\n| Delete-account and gateway endpoints, where every neighbour is `suppliers/`.\n\n| Service-level toasts\n| `PaymentBatchRepositoryService` (eight messages),\n  `MarketplaceRepository`, `LookupRepository`.\n|===\n"},{"component":"internal-admin","module":"reference","path":"reference:environment-variables.adoc","title":"Environment configuration keys","description":"Every IEnvironment key, its source shell variable, and its dev and production values.","url":"https://docx.payability.com/internal-admin/reference/environment-variables.html","navOrder":54,"source":"= Environment configuration keys\n:description: Every IEnvironment key, its source shell variable, and its dev and production values.\n\nThe contract is `src/environments/environment.interface.ts` — 33 keys, the only\ntracked file in that folder. The generated `environment*.ts` files are `.gitignore`d.\n\nGeneration chain and the four-step process for adding a key:\nxref:ops:environments.adoc[environments].\n\n== Backend base URLs\n\n[cols=\"2,2,2,2\",options=\"header\"]\n|===\n| `IEnvironment` key | Shell variable | Dev value | Production value\n\n| `internalApiUrl`\n| `FRONTEND_INTERNAL_URL` + `FRONTEND_INTERNAL_API_URL`\n| `https://<env>.<domain>/database_v2/internal/`\n| `https://dbapi-internal.payability.com/bin/internal/`\n\n| `cliApiUrl`\n| `FRONTEND_INTERNAL_URL` + `/cli/`\n| `https://<env>.<domain>/database_v2/cli/`\n| `https://dbapi-internal.payability.com/bin/cli/`\n\n| `supWidgetsApiUrl`\n| `FRONTEND_INTERNAL_URL` + `/sup_widget/`\n| `https://<env>.<domain>/database_v2/sup_widget/`\n| `https://dbapi-internal.payability.com/bin/sup_widget/`\n\n| `authToolsApiUrl`\n| `FRONTEND_AUTH_TOOLS_API_URL`\n| `https://0vd7bg72e4.execute-api.us-east-1.amazonaws.com/stage/`\n| `https://api.payability.com/authtools/`\n\n| `actionsToolsUrl`\n| `FRONTEND_ACTION_TOOLS_API_URL`\n| `https://vn1s47jzkd.execute-api.us-east-1.amazonaws.com/stage/`\n| `https://api.payability.com/actionstools/`\n\n| `eventLoggerUrl`\n| `FRONTEND_EVENT_LOGGER_API_URL`\n| `https://epu44ua492.execute-api.us-east-1.amazonaws.com/test_rules2/`\n| `https://api.payability.com/eventlogger_v2/`\n\n| `eventRulesOldUrl`\n| `FRONTEND_EVENT_RULES_OLD_API_URL`\n| `https://drkrpkdxe4.execute-api.us-east-1.amazonaws.com/stage/`\n| `https://api.payability.com/eventlogger/`\n\n| `messagingUrl`\n| `FRONTEND_MESSAGING_API_URL`\n| `https://argumexh08.execute-api.us-east-1.amazonaws.com/stage/`\n| `https://api.payability.com/messaging/`\n\n| `workflowApiUrl`\n| `FRONTEND_TASK_MANAGER`\n| `https://<env>.<domain>/b2btaskmanager/`\n| `https://workflow.payability.com/b2btaskmanager/`\n\n| `b2bDocumentsApiUrl`\n| `FRONTEND_B2B_DOCUMENTS_API_URL`\n| `https://<env>.<domain>/b2bdocument/`\n| `https://doc-api.payability.com/b2bdocument/`\n\n| `pybDocumentsApiUrl`\n| `FRONTEND_PYB_DOCUMENTS_API_URL`\n| `https://<env>.<domain>/pyb-documents/`\n| `https://pyb-documents.payability.com/`\n\n| `documentsApiUrl`\n| `FRONTEND_DOCUMENTS_API_URL`\n| `https://ajwk785vec.execute-api.us-east-1.amazonaws.com/dev/`\n| `https://api.payability.com/documents/`\n\n| `s3FilesProviderApi`\n| `S3_FILES_PROVIDER_API`\n| `https://<env>.<domain>/s3-files-provider/`\n| `https://s3-files-provider.payability.com/`\n\n| `marketplaceApiUrl`\n| `MARKETPLACE_API`\n| `https://marketplace-api-dev2.payability.dev/`\n| `https://marketplace-api.payability.com/`\n\n| `automaticMarketplaceApiUrl`\n| `AUTOMATIC_UW_TOOL_API`\n| `https://<env>.<domain>/automatic-uw-tool-web-api/`\n| `https://automatic-uw-tool.payability.com/`\n\n| `evaApiUrl`\n| `EVA_API_CUSTOM_URL` ?? `EVA_API`\n| `https://<env>-eva-fe.<domain>/` (or `http://localhost:4600/` locally)\n| `https://prod-eva-fe.payability.com/`\n\n| `supplierUrl`\n| `FRONTEND_SUPPLIER_URL`\n| `https://<env>-supplier.<domain>`\n| `https://supplier.payability.com`\n\n| `supplierReferralUrl`\n| `FRONTEND_REFERRAL_URL`\n| `https://go-test.payability.com/`\n| `https://goto.payability.com/`\n|===\n\n[NOTE]\n====\n`<env>` is `ENV_NAME`, `<domain>` is the `DOMAIN` argument\n(`dev.payability.com` by default, `payability.com` for production).\n\nServices *hosted per environment* interpolate `<env>.<domain>` and are therefore\nisolated. Lambda-backed services are fixed API Gateway URLs and are **shared across\nevery dev environment** — `actionsToolsUrl`, `authToolsApiUrl`, `eventLoggerUrl`,\n`eventRulesOldUrl`, `messagingUrl`, `documentsApiUrl`, plus `marketplaceApiUrl`. A\nchange made through one dev environment can be visible in another.\n====\n\nWhich service uses which base URL:\nxref:services-and-endpoints.adoc[services and endpoints]. What each backend does:\nxref:architecture:integration-landscape.adoc[the integration landscape].\n\n== Behaviour and identity\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Key | Source | Notes\n\n| `production`\n| derived\n| `true` only when `ENV_NAME=release`.\n\n| `envName`\n| derived\n| `'prod'` when `ENV_NAME=release`, otherwise `'test'` — **including for staging**.\n  Read by `themeConfigData`, `AppStageInfoComponent` and `showStoreDebugger`.\n\n| `VERSION`\n| `require('../../package.json').version`\n| Evaluated in the browser, not baked in as a literal. Shown in the sidebar and on the\n  login screen.\n\n| `gitHash`\n| `git rev-parse HEAD`\n| First 40 characters. `AppStageInfoComponent` shows the first 8 (when enabled).\n\n| `lastBuildDate`\n| `new Date().toISOString().substr(0, 16)`\n| ISO to the minute.\n\n| `domain`\n| `DOMAIN` argument\n| Used for the EVA cookie domain and iframe origin checks.\n\n| `instanceId`\n| hardcoded `8001`\n| Sent as `X-InstanceId` / `X-EventLogger-InstanceId` by `CrmService`,\n  `GlobalSettingsService`. `EventsService` hardcodes `'8001'` separately and\n  `MonitorService` hardcodes `8003`.\n\n| `workflowGciId`\n| `FRONTEND_TASK_MANAGER_GCI`\n| Dev: computed by `deployment/generate_gci.py` from `ENV_NAME` (first six digits of\n  its SHA-1). Production: hardcoded `1`.\n|===\n\n== Error display\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Key | Value | Effect\n\n| `displayErrorsInConsole`\n| hardcoded `true`\n| `AppErrorHandler` logs errors in production. When false it prints\n  \"Check sentry logs or run in dev mode.\" instead.\n\n| `displayErrorsPopup`\n| hardcoded `false`\n| **Read but never used** — `handleErrorProd` ignores it.\n|===\n\nSee xref:platform:error-handling.adoc[error handling].\n\n== Third-party keys\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Key | Source | Notes\n\n| `googleAuthClientId`\n| **hardcoded in `set-env.ts`**\n| `592848749680-3fs9ru3khk5u53i92hgf0v0giuhds1ee.apps.googleusercontent.com` — the\n  *same* client id for every environment. Only specific localhost ports are\n  registered.\n\n| `sentryUrl`\n| **hardcoded in `set-env.ts`**\n| Identical in every environment. **No Sentry SDK is installed and no code reads\n  this.** See xref:platform:observability.adoc[observability].\n\n| `frontendSegmentIOCode`\n| `FRONTEND_SEGMENTIO_CODE`\n| Dev `qQtIVnv1eERcK7bsQs8ITA48ak8kzxA3/`, production\n  `J8O1DVU1ToteAHLWlfsqlD2uosWt5zhA/`. `set-env.ts` appends the trailing `/`\n  because it is concatenated straight into the CDN script URL.\n|===\n\n== Feature flags\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Behaviour\n\n| `featureFlags`\n| `process.env.FEATURE_FLAGS` or `''`. **Neither `.cfg` template exports\n  `FEATURE_FLAGS`**, so build flags are empty unless the pipeline sets it. Flags are\n  managed through the `FEATURE_FLAGS_IA` / `FEATURE_FLAGS_EVA` global settings\n  instead — and global settings *beat* build flags.\n|===\n\nFormat: comma-separated, no spaces. An empty string disables all build flags. See\nxref:platform:feature-flags.adoc[feature flags] and\nxref:feature-flag-catalog.adoc[the flag catalogue].\n\n== Supplier sites\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Value\n\n| `supplierSiteVersions`\n| A hardcoded array in `set-env.ts`: `c2fo`, `morecommerce`, `neweggcapital`,\n  `neweggcapital2`, `onboarding`, `onboarding1`, `supplier`, `supplier2`,\n  `supplier3`, `supplier4`, `volusion`.\n|===\n\nThese are the white-label self-service front ends. `SupplierDetailsModel.supplierSite`\nnames which one a supplier uses. In test environments only `supplier` and\n`supplier4` are valid login-as-supplier targets\n(`messageCodes.inTestEnvironmentsCanOnlyTo`).\n\nA partly overlapping list drives the `noindex` meta tag in `AppModule`:\n`volusion`, `pro`, `lendio`, `neweggcapital`, `morecommerce`, `crm2`, `c2fo`.\n\n== Reading configuration\n\nAlways through `AppConfigService`:\n\n[source,typescript]\n----\nconstructor(private appConfig: AppConfigService) {\n  this.apiUrl = appConfig.env.internalApiUrl;\n}\n----\n\nServices that still import `environment` directly (legacy; do not extend):\n`EventsService`, `EvaService`, `LookupRepository`, `PendingReviewService`,\n`FeatureFlagsService`, `GatewayService`, `MarketplaceRepository`,\n`VpnTesterService`, `CommunicationService`, `VersionService`,\n`FeatureFlagsGeneralGuard`, `storeDebugger`, `main.ts`.\n\n== Adding a key — the four files\n\n. `src/environments/environment.interface.ts` — add to `IEnvironment`.\n. `deployment/setup_template-dev.cfg` — `export` the shell variable.\n. `deployment/setup_template-production.cfg` — same.\n. `deployment/set-env.ts` — add the field to the generated template literal.\n\nThen `./deployment/setenv.sh <env>` to regenerate locally.\n\n[WARNING]\n====\nSkipping step 2 or 3 gives you a working local value and `undefined` everywhere else.\nTypeScript will not catch it — the generated object satisfies the interface by simply\nomitting the property.\n====\n\n== Dead configuration\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Item | Status\n\n| `deployment/setup_template-legacy.cfg`\n| Not sourced by `setenv.sh`. References `test2-api.payability.com`,\n  `workflow-test`, `b2btaskmanager2`, `GCI=12`. Historical only.\n\n| `sentryUrl`\n| Configured, never read.\n\n| `displayErrorsPopup`\n| Read, never acted on.\n\n| Commented-out `supplierApiUrl`\n| Removed in `SupplierService` with a dated `APPS-438` note.\n|===\n"},{"component":"internal-admin","module":"reference","path":"reference:feature-flag-catalog.adoc","title":"Feature flag catalogue","description":"Every flag in the FEATURE_FLAGS enum, what it gates, and where it is checked.","url":"https://docx.payability.com/internal-admin/reference/feature-flag-catalog.html","navOrder":55,"source":"= Feature flag catalogue\n:description: Every flag in the FEATURE_FLAGS enum, what it gates, and where it is checked.\n\nThe vocabulary is `{source-root}/shared/feature-flags/feature-flags.const.ts`. Fourteen\nflags. Mechanics — the three layers, their precedence and the four application\nmethods — are in xref:platform:feature-flags.adoc[feature flags].\n\n== Catalogue\n\n[cols=\"2,3,3\",options=\"header\"]\n|===\n| Flag | Gates | Checked in\n\n| `SUPPLIER_ACCOUNT_HEALTH`\n| Amazon account-health data on the supplier header, and the health tab.\n| `suppliers/supplier/supplier-box-header/supplier-box-header-old/supplier-box-header.component.ts`\n  (`isEnabled`)\n\n| `TASKS_MID_VER_SIMPLE_QUERY`\n| A simpler task query/transition path in the task list and counters.\n| `crm-tools/tasks/task-list/task-list/transition/task-list-transition.component.ts`,\n  `shared/task-count-badge/task-count-badge.component.ts`\n\n| `UNDERWRITING_TRACKER`\n| The *UW Tracker* sidebar item, and an underwriting-tracker link on the marketplace\n  form.\n| `shared/sidenav/sidenav-menu/sidenav-menu.component.html`,\n  `marketplaces/marketplace/marketplace.component.html`\n  (both `*appEnabledByFeatureFlag`)\n\n| `BANK_CATEGORIES`\n| The *bank transactions categories* underwriting tab\n  (`ExternalReports.BANK_CATEGORIES`, `PL-BT`).\n| `suppliers/supplier/supplier-underwriting/supplier-underwriting.component.ts`\n\n| `EVA_ONBOARDING_SUMMARY`\n| The EVA-rendered onboarding summary instead of the Angular one.\n| `suppliers/supplier/supplier-onboarding/supplier-onboarding-details/supplier-onboarding-details.component.ts`\n\n| `ACCESS_ONBOARDING_PREDICTIONS`\n| The onboarding ML-prediction underwriting tab (`BR-AC-ON`).\n| `supplier-underwriting.component.ts`\n\n| `ACCESS_DAILY_PREDICTIONS`\n| The daily ML-prediction underwriting tab (`BR-AC-DL`).\n| `supplier-underwriting.component.ts`\n\n| `TWO_FA`\n| Supplier two-factor management on the General tab.\n| `suppliers/supplier/supplier-general/supplier-general.component.html`\n  (`*appEnabledByFeatureFlag`)\n\n| `UNDERWRITING_CHECK_TOOLS_BEFORE_APPROVING`\n| Requires UW tool results before an underwriting approval is allowed.\n| `suppliers/supplier/supplier-underwriting/underwriting-initial/underwriting-initial-footer/underwriting-initial-footer.component.ts`\n\n| `PYB_DOCUMENTS`\n| Routes document handling to the `pybDocumentsApiUrl` backend. The most widely\n  checked flag.\n| `shared/apps-document/apps-document.service.ts`,\n  `crm-tools/files/file-upload/file-upload.component.ts`,\n  `crm-tools/comments/comment-list/comment-list.component.ts`,\n  `crm-tools/shared/item-with-attachment.component.ts`,\n  `crm-tools/shared/b2b-preview/…`,\n  `suppliers/supplier/kyc-documentation/kyc-documentation.component.ts`\n\n| `EVA_SUPPLIER_MENU`\n| The EVA-rendered supplier header/menu.\n| `suppliers/supplier/supplier-box-header/eva/supplier-box-header-eva.component.ts`\n\n| `CHECK_VPN`\n| Starts `VpnTesterService` — a 30-second `OPTIONS` probe that toasts when off-VPN.\n| `{source-root}/app.component.ts` (in the **constructor**)\n\n| `DELETE_SUPPLIER`\n| Supplier account deletion on the General tab.\n| `suppliers/supplier/supplier-general/supplier-general.component.html`\n  (`*appEnabledByFeatureFlag`)\n\n| `CARD_MANAGEMENT_AIRWALLEX`\n| The Airwallex card programme — provider choice and card list.\n| `suppliers/supplier/choose-card-type/choose-card-type.component.ts`,\n  `suppliers/supplier/supplier-payment/supplier-payment-list/supplier-payment-list.component.ts`\n|===\n\n== Route-level flag gating\n\nOnly two routes use `FeatureFlagGuard` (which redirects to `/404` when the flag is\noff), and both take the flag from route `data`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Route | Guard\n\n| `/suppliers/:supplierKey/new-underwriting`\n| `canLoad`/`canActivate: [FeatureFlagGuard]`\n\n| `/suppliers/:supplierKey/underwriting/documentation`\n| `canLoad`/`canActivate: [FeatureFlagGuard]`\n|===\n\n[IMPORTANT]\n====\nEvery *other* flag hides UI without blocking the route.\n`FeatureFlagsGeneralGuard` — which is on nearly every route — only *initialises* flags\nand always resolves `true`. So a flag-hidden feature's URL usually still renders. The\n`UNDERWRITING_TRACKER` menu item disappears but `/underwriting/tracker` still loads\nthe EVA frame.\n====\n\n== Flags checked outside the enum\n\nTwo related mechanisms use flag-shaped strings that are not `FEATURE_FLAGS` members:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| String | Detail\n\n| `'🎪'`\n| `FeatureFlagsEvaService.ALWAYS_ON_FLAGS` — an emoji sentinel forced to `true` for\n  EVA and unioned into EVA's fetched flag set. Intentional. See\n  xref:domains:admin.adoc[DEV Admin].\n\n| `NEW_SEARCH_ENGINE`\n| Exported from `search-engine.service.ts` as a flag name, but the check is\n  short-circuited to `if (1 === 1)`. Dead.\n|===\n\n== Storage keys\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Key | Layer\n\n| `environment.featureFlags`\n| Build. Comma-separated, no spaces. Empty in both `.cfg` templates.\n\n| Global setting `FEATURE_FLAGS_IA`\n| Environment-wide, `config_value = { flags: 'A,B,C', lastChange: <ms> }`.\n\n| Global setting `FEATURE_FLAGS_EVA`\n| Same, for EVA.\n\n| `sessionStorage['FeatureExecFlags']`\n| Per-tab overrides for this app, as `[{ FLAG: boolean }, …]`.\n\n| `sessionStorage['FeatureExecFlagsLastChange']`\n| Override timestamp; compared against the global setting's `lastChange`.\n\n| `sessionStorage['FeatureExecFlagsEva']` / `…LastChangeEva`\n| The EVA equivalents.\n|===\n\n== Precedence reminder\n\n[source]\n----\nexec = globalSettings                       ← seeds the set (NOT build flags)\n     + session overrides (true)             ← only if newer than the setting's lastChange\n     − session overrides (false)\n----\n\n`isEnabled()` reads *build flags + session storage*, and is only correct after\n`initFF()` has run — which `FeatureFlagsGeneralGuard` does on every guarded\nnavigation.\n\nConsequences that cause confusion:\n\n* A flag in the build but absent from `FEATURE_FLAGS_IA` resolves to **off**.\n* Saving to global settings invalidates everyone's session overrides.\n* A flag typo in the environment string is silent — the cast to `FEATURE_FLAGS[]` is\n  unchecked.\n* A leading comma (`',A,B'`) clears the whole list.\n\nFull analysis: xref:platform:feature-flags.adoc[feature flags].\n\n== Managing flags\n\n`/admin/feature-flags` (needs `DEV`) shows one table per environment\n(`IA`, `EVA`) with three independent columns per flag — `build`,\n`globalSettings`, `exec` — and can toggle a session override, clear overrides, persist\nthe set to global settings, or share a configuration by URL.\n\nIn non-production the resolved table is printed to the console once per page load by\n`FeatureFlagsGeneralGuard`.\n\nSee xref:domains:admin.adoc[DEV Admin].\n\n== Adding a flag\n\n. Add the member to `FEATURE_FLAGS` in `feature-flags.const.ts`.\n. Apply it — `isEnabled`, `*appEnabledByFeatureFlag`, or `FeatureFlagGuard` with\n  `data.enabledByFeatureFlag`.\n. If it gates a menu item, wrap the item *and* decide whether the route should also be\n  blocked.\n. Enable it per environment at `/admin/feature-flags` → save to global settings.\n. Add a row to this page.\n"},{"component":"internal-admin","module":"reference","path":"reference:permission-catalog.adoc","title":"Permission catalogue","description":"Every USER_PERMISSION code, what it governs, and where it is enforced in the front end.","url":"https://docx.payability.com/internal-admin/reference/permission-catalog.html","navOrder":56,"source":"= Permission catalogue\n:description: Every USER_PERMISSION code, what it governs, and where it is enforced in the front end.\n\nThe vocabulary is `{source-root}/shared/users/user-permissions.enum.ts` — 40 codes.\nMechanics (the two deny filters, the four gating mechanisms, supplier-scoped grants\nand sub-permissions) are in xref:platform:authorization.adoc[authorisation].\n\n[IMPORTANT]\n====\nClient-side gating is *advisory*. The backend enforces every protected operation. A\ncode with no front-end enforcement listed below is still meaningful server-side.\n====\n\n== The two deny filters\n\nThese are restrictions, not grants — holding one removes access.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Enforced in\n\n| `REST_UW_ACCESS`\n| Route clauses in `suppliers/`, `crm-tools/tasks-routing`, `emails/`, `manage/`,\n  `marketplaces/`, `processes/`, `reports/`, `underwriting/`;\n  `sidenav-menu.component.html`;\n  a `restricted-access` body class from `{source-root}/app.component.ts`;\n  `search-overlay.component.ts`;\n  the supplier box headers (both generations);\n  `additional-business-owners`, `kyc-documentation`, `supplier-general`,\n  `supplier-underwriting`, `supplier-information`, `supplier-business-address`,\n  `underwriting-daily`, `underwriting-initial`, `ucc-list`;\n  **and the KYC guardrail audit trail** in `shared/monitor.service.ts`.\n\n| `ADVANCE_AUDITORS`\n| The same route tables and sidebar clauses, plus\n  `suppliers/store/supplier/supplier.effects.ts` (funding, balance and contract calls\n  are replaced with `of(null)`), `crm-tools/store/crm.effects.ts`,\n  `spinner-button.component.html`, `supplier.component.html`, and ~12 underwriting\n  card templates (`agreement-review`, `amazon-information`, `credit-check`,\n  `international-supplier`, `personal-background-check`, `underwriting-files`,\n  `underwriting-initial-header`, `background-report-card-header`, `uw-review`, …).\n  Also the KYC guardrail trail.\n|===\n\nThe three reusable route spreads that combine them are defined in\n`suppliers/suppliers-routing.routes.ts` — `RESTRICTED_UW_ACCESS_CONFIG`,\n`RESTRICTED_AUDITORS_ACCESS_CONFIG`,\n`RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG`. See\nxref:routes.adoc[the route catalogue].\n\n== Login and session\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Governs / enforced in\n\n| `AUTH_TOKEN`\n| Token issuance. No front-end check.\n\n| `SUP_LOGIN`\n| Supplier-side login. No front-end check.\n\n| `INT_LOGIN`\n| Internal login. Used as a *filter* in\n  `UsersService.getUsers()` — `?permCode=INT_LOGIN` selects internal users.\n\n| `DELETED`\n| Soft-deleted account marker. No front-end check.\n|===\n\n== Broad roles\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Governs / enforced in\n\n| `INT_SUPER_USER`\n| Broad administrative access. No explicit front-end check — it is a backend role.\n\n| `VIEW_ONLY`\n| Read-only access. No explicit front-end check.\n\n| `FINANCE`\n| **Allow-list** on `/suppliers/:key/underwriting/background-reports`\n  (`{ only: FINANCE }`). Also checked imperatively in\n  `supplier-marketplace-edit.component.ts` (`hasPermission`),\n  `marketplace-access-details.component.ts` (`checkPermission`),\n  `edit-marketplace-statement.component.ts`,\n  `supplier-underwriting.component.ts`, `ucc-create.component.ts`, and\n  `supplier-general.component.html`.\n\n| `MARKETING`\n| No explicit front-end check.\n\n| `CUST_SERVICE`\n| `supplier-underwriting/supplier-information/supplier-information.component.ts`.\n\n| `Sales`\n| No explicit front-end check. Note the non-uppercase enum value.\n\n| `UNDERWRITING`\n| No explicit front-end check.\n\n| `PROD_MGR`\n| No explicit front-end check.\n\n| `US` (value `'us'`)\n| `suppliers/supplier/cancel-supplier/cancel-supplier.component.ts`. Lowercase value.\n\n| `DEV`\n| **Allow-list** on `/admin` (`{ only: DEV, redirectTo: '/' }`) and the *DEV Admin*\n  sidebar item (`*ngxPermissionsOnly`). Also checked in\n  `documentation-card-header.component.ts`,\n  `uw-personal-checks-card.component.ts` and\n  `advance-offer-summary.component.ts`.\n\n| `dev_support`\n| `edit-marketplace-statement.component.ts`. Lowercase value. Does **not** grant\n  `/admin`.\n|===\n\n== Administration\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Governs / enforced in\n\n| `INT_UPDATE`\n| General update rights. No explicit front-end check.\n\n| `INT_PERMS_ADM`\n| Users and permission groups (`/manage/users`, `/manage/perm-groups`). Enforced by\n  the backend; no route clause.\n\n| `INT_GLOBAL_SETTINGS`\n| Global settings. Checked in\n  `manage/onboarding/onboarding-list.component.ts` and\n  `onboarding-mp-list.component.ts`.\n\n| `INT_EMAIL_SETTINGS`\n| Email settings. Checked in\n  `manage/email-settings/email-settings-list/email-settings-list.component.ts`.\n\n| `INT_RULES_UPDATE`\n| Event rules. No explicit front-end check\n  (`rules-list.component.ts` checks `CRM_ADMIN` instead).\n\n| `INT_REP_ADMIN`\n| Report administration. Checked in\n  `reports/report-list/report-list.component.ts`.\n\n| `CRM_ADMIN`\n| The broadest *imperative* permission in the front end:\n  `manage/event-rules-2`, `manage/event-rules/rules-list`,\n  `manage/global-settings`, `manage/month-end-close`,\n  `manage/onboarding/onboarding-list` and `onboarding-mp-list`,\n  `manage/email-settings/email-settings-list`,\n  `shared/sidenav/sidenav-menu/sidenav-menu.component.ts`,\n  and `UserModel.isSuperUser()` — which is a `CRM_ADMIN` check despite its name.\n|===\n\n== Money operations\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Governs\n\n| `INT_MP_PMTS`\n| Marketplace payments (*Add MP Payment*, *Add Zero MP Payment*).\n\n| `INT_RCV`\n| Receivables (*Add receivable*).\n\n| `INT_XACT_CNCL`\n| Transaction cancellation.\n\n| `INT_FEE_UPD`\n| Fee updates (*Adjust Payability Fee*, the fees tab).\n\n| `INT_PMT_ADMIN`\n| Payment administration (batches).\n\n| `INT_PMT_REVIEW`\n| Payment review (batch approval).\n\n| `MANAGE_PMT`\n| Payment management.\n\n| `MANAGE_BAD_DEBT`\n| Write-offs and bad-debt recovery.\n|===\n\nNone of these carry an explicit front-end check in the current code — they are enforced\nby the backend, and the corresponding UI is reached through the Actions menu. See\nxref:domains:supplier-lifecycle.adoc[lifecycle actions and transactions].\n\n== Supplier data\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Governs\n\n| `INT_SUP_CRED`\n| Supplier credentials (password reset, login-as-supplier).\n\n| `MANAGE_LINKING_PAYMENT_GATEWAYS`\n| Linking payment gateways to a marketplace record. See\n  xref:domains:marketplaces.adoc[marketplaces].\n|===\n\n== Sub-permissions\n\nThe only codes checked through `SubPermissionsGuard` and\n`UsersService.checkPermission`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Code | Enforced in\n\n| `MP_LOANS_MODIFY`\n| Route `data.requireOneOfSubPermissions` on\n  `/suppliers/:key/amazon-lending/add-loan` and `…/loan/:loanKey`;\n  `supplier-amazon-lending-list.component.ts` and\n  `supplier-amazon-lending-loan.component.ts` (`checkPermission`).\n\n| `MP_LOANS_DELETE`\n| Route data on `…/loan/:loanKey`;\n  `supplier-amazon-lending-loan.component.ts`.\n\n| `MP_STATEMENTS_MODIFY`\n| `supplier-marketplace-statements.component.ts`.\n\n| `MP_STATEMENTS_DELETE`\n| `supplier-marketplace-statements.component.ts`.\n|===\n\nSub-permissions are resolved via `GET perms/:permCode/sub-perms`, and are also what\n`EvaComponent` forwards to EVA as `EvaPermissions`. Note the cache in\n`UsersService.getPermissionSubPerms` **ignores `permCode`** — see\nxref:platform:authorization.adoc[authorisation].\n\n== Allow-list clauses (the only two)\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Route | Clause\n\n| `/admin`\n| `{ only: DEV, redirectTo: '/' }`\n\n| `/suppliers/:key/underwriting/background-reports`\n| `{ only: FINANCE }`\n|===\n\n[CAUTION]\n====\nAn `only` clause is an allow-list: it blocks *everyone* without that exact code,\nincluding super users. `FINANCE` on background reports and `DEV` on `/admin` behave\nthat way deliberately.\n====\n\n== Permission models\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Model | Fields\n\n| `UserPermModel`\n| `permCode`, `permDesc`, `supplierKey` *(non-null = supplier-scoped grant)*,\n  `loginPermKey` *(the grant id, needed to revoke)*.\n\n| `PermissionBaseModel`\n| `generalPerm`, `permCode`, `permDesc`.\n\n| `PermissionModel` (extends the above)\n| `assignable` *(may be granted in the UI)*, `specSupPerm` *(may be\n  supplier-scoped)*, `type = 'group'`, plus `id`/`key`/`name` getters.\n|===\n\n[IMPORTANT]\n====\n`ngx-permissions` is loaded with `perms.map(p => p.permCode)` — the `supplierKey`\nscope is **discarded**. Route and template gating are therefore scope-blind. Only\n`UsersService.checkPermission` (and the backend) can honour a supplier-scoped grant.\n====\n\n== Checking permissions in code\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Call | Returns\n\n| `NgxPermissionsService.getPermission(code)`\n| Synchronous — the object or `undefined`.\n\n| `NgxPermissionsService.hasPermission(code)`\n| `Promise<boolean>`.\n\n| `UsersService.checkPermission(code, user?)`\n| `Observable<boolean>`.\n\n| `UsersService.checkPermissions(codes, user)`\n| `Observable<boolean>` — all codes.\n\n| `UsersService.checkGeneralPermission(s)`\n| `Observable<boolean>` — checks the `generalPerm` flag.\n|===\n\nAll five are in live use. Match the local convention of the file you are editing.\n\n== Administering grants\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Operation | Endpoint\n\n| List permissions/groups\n| `GET perms`\n\n| A user's grants\n| `GET logins/:loginKey/perms`\n\n| Sub-permissions of a permission\n| `GET perms/:permCode/sub-perms`\n\n| Grant\n| `addPermissionToLogin(loginKey, { permCode, supplierKey })`\n\n| Revoke\n| `removePermissionFromLogin(loginKey, loginPermKey)` — by grant id, not code\n|===\n\nSee xref:domains:manage.adoc[manage].\n\n== Adding a permission\n\n. Add the code to `USER_PERMISSION` — matching the backend's exact string, including\n  any non-uppercase casing.\n. Gate the route (`data.permissions`) and/or the template\n  (`*ngxPermissionsOnly` / `*ngxPermissionsExcept`), and update the sidebar entry to\n  match.\n. For a sub-permission, use `data.requireOneOfSubPermissions` with\n  `SubPermissionsGuard`.\n. Add a row to this page and, if it changes a route, to\n  xref:routes.adoc[the route catalogue].\n"},{"component":"internal-admin","module":"reference","path":"reference:event-catalog.adoc","title":"Event and message catalogue","description":"Domain event types and targets, event-rule actions, the iframe postMessage protocol, and the in-app event bus.","url":"https://docx.payability.com/internal-admin/reference/event-catalog.html","navOrder":57,"source":"= Event and message catalogue\n:description: Domain event types and targets, event-rule actions, the iframe postMessage protocol, and the in-app event bus.\n\nFour unrelated things are called \"events\" in this codebase. This page catalogues all\nfour.\n\n== 1. Domain event types\n\n`{source-root}/manage/event-rules/event-rule.models.ts` — what an event rule can\nmatch.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `eventTypes` member | Produced by\n\n| `analitics` *(sic)*\n| The analytics stream. `MonitorService` sends\n  `X-EventLogger-Type: analitics`.\n\n| `api-event`\n| API-originated events.\n\n| `cli-event`\n| CLI service operations.\n\n| `event`\n| Generic domain events.\n\n| `messaging-log`\n| Outbound message log entries.\n\n| `messaging-result`\n| Delivery results.\n\n| `task-change-event`\n| CRM task transitions.\n\n| `underwriting-event`\n| Underwriting decisions and changes.\n|===\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `eventTargets` member | Subject of the event\n\n| `compliance`\n| A compliance/KYC action.\n\n| `crm-task`\n| A CRM task.\n\n| `email`\n| An email.\n\n| `marketplace`\n| A marketplace or marketplace link.\n\n| `supplier`\n| A supplier — the common case; the target id is the supplier key.\n|===\n\nThe target id travels as a header: `X-EventLogger-TargetUUID` on event-logger calls,\n`X-Actions-TargetUUID` on Actions Tools calls. `X-InstanceId` /\n`X-EventLogger-InstanceId` carry `env.instanceId` (8001) — except\n`MonitorService`, which hardcodes `8003`.\n\nStores: `eventLoggerUrl` (v2 — `es-changelog`, `track`) and\n`eventRulesOldUrl` (v1 — `event_rules/`, plus a raw Elasticsearch endpoint at `es`\nagainst the index `em_events`).\n\nSee xref:platform:events.adoc[events].\n\n== 2. Event-rule actions\n\n`Action` static members in the same file:\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Constant | Wire value | Status\n\n| `Action.CREATE_TASK`\n| `create-task`\n| Active\n\n| `Action.SEND_EMAIL`\n| `send-email`\n| Active\n\n| `Action.EDIT_SUPPLIER`\n| `edit-supplier`\n| Active\n\n| `Action.SEND_TO_EVENT_BRIDGE`\n| `push-to-event-bridge`\n| Active\n\n| `Action.SLACK_NOTIF`\n| `notify-on-slack`\n| **Defined but commented out of `actionTypes`** — not selectable\n|===\n\nAction fields: `type`, `template`, `to`, `contextMapping`, `dataMapping`, `data`,\n`delay`, `delayTime`, `delayPeriod`, `delayEnabled`.\n\nDelay units (`send-email.component.ts`): `MINUTES = 60`, `DAYS = 86400`,\n`WEEKS = 604800` seconds. No hours, no seconds.\n\nEventBridge action names must start with `em_event_rule_`\n(`PREFIX_EVNET_RULE`, misspelled constant, correct value).\n\n=== Filter operators\n\n`Filter` parses expressions into `{ name, op, val }`:\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Expression | Stored `op` | Rendered back as\n\n| `=`  | `eq`  | `=`\n| `!=` | `ne`  | `!=`\n| `<`  | `lt`  | `<`\n| `>`  | `gt`  | `>`\n| `>=` / `=>` | `gte` | `>=`\n| `<=` / `=<` | `lte` | `=<`\n|===\n\nRegex: `/([a-zA-Z.]+ ) *(!=|=|<|>|>=|<=|=<|=>) *([^ ]+)/` — note the required\ntrailing space in the field-name group, and that field names may contain dots\n(`data.target_type`).\n\nThe same `{ name, op, val }` shape is the API's query language, used by\n`EventsService.historyList`.\n\n== 3. Iframe `postMessage` protocol\n\n`{source-root}/shared/iframe/communication/message-type.enum.ts`. Values are\nhuman-readable strings. Envelope:\n`{ pluginIframeId, payload: { type: MESSAGE_TYPE, data? } }`.\n\n`MESSAGE_SOURCE` is `PARENT` (`'parent'`) or `CHILD` (`'child'`).\n\n=== Configuration\n\n[cols=\"2,2,1,2\",options=\"header\"]\n|===\n| Type | Value | Dir. | Payload / effect\n\n| `GENERAL__CONFIG_REQUEST`\n| `[GENERAL] Config Request`\n| EVA→IA\n| EVA asks for its config.\n\n| `GENERAL__CONFIG_RESPONSE`\n| `[GENERAL] Config Response`\n| IA→EVA\n| `{ flags: string[], permissions: EvaPermissions }`.\n\n| `GENERAL__CONFIG_UPDATE`\n| `[GENERAL] Config Update`\n| EVA→IA\n| EVA reports its own available/build flags.\n\n| `FEATURE_FLAGS__RESET`\n| `[FEATURE FLAGS] Reset`\n| IA→EVA\n| Clear EVA's flag overrides.\n|===\n\n=== Navigation\n\n[cols=\"2,2,1,2\",options=\"header\"]\n|===\n| Type | Value | Dir. | Effect\n\n| `GENERAL__NAVIGATE_BY_LINK`\n| `[GENERAL] Navigate By Link`\n| EVA→IA\n| `router.navigate([data.url])`.\n\n| `GENERAL__ONLY_CHANGE_ROUTE`\n| `[GENERAL] Only Change Route`\n| EVA→IA\n| Same handler — behaviourally identical today.\n\n| `GENERAL__CHANGE_PARENT_ROUTE`\n| `[GENERAL] Change Parent Route`\n| IA→EVA\n| Sent to the top bar on every `NavigationEnd`\n  (`changeParentRouteCommand(url)`).\n\n| `MAIN_HEADER__NAVIGATE_BY_DEAL_LINK`\n| `[MAIN HEADER] Navigate By Deal Link`\n| EVA→IA\n| Jump to the external sales CRM via `extDealId`.\n|===\n\n=== Supplier state\n\n[cols=\"2,2,1,2\",options=\"header\"]\n|===\n| Type | Value | Dir. | Effect\n\n| `GET_STORE__SUPPLIER_DETAILS`\n| `[GET STORE] Supplier Details`\n| EVA→IA\n| IA waits for a matching `supplierKey`, then replies once.\n\n| `UPDATE_EVA_STORE__SUPPLIER_DETAILS`\n| `[UPDATE EVA STORE] Supplier Details`\n| IA→EVA\n| Push details into EVA's store — sent on *every* change.\n\n| `GENERAL__UPDATE_SUPPLIER_DETAILS_STORE`\n| `[GENERAL] Update Supplier Details Store`\n| EVA→IA\n| Dispatches `UpdateSupplierDetailsModelByEva`.\n\n| `MAIN_HEADER__REFETCH_SUPPLIER_DETAILS`\n| `[MAIN HEADER] Refetch Supplier Details`\n| IA→EVA\n| Ask EVA's header to re-fetch.\n\n| `SUPPLIER_MENU__GET_MENU_CONDITIONS`\n| `[SUPPLIER MENU] Get Menu Conditions`\n| both\n| IA replies `{ isAmazonLending, isSellerCard }`.\n|===\n\n=== Actions\n\n[cols=\"2,2,1,2\",options=\"header\"]\n|===\n| Type | Value | Dir. | Effect\n\n| `MAIN_HEADER__LOGIN_AS_SUPPLIER`\n| `[MAIN HEADER] Login As Supplier`\n| EVA→IA\n| Login-as-supplier flow (needs the browser extension).\n\n| `MAIN_HEADER__LOGIN_TO_AMAZON`\n| `[MAIN HEADER] Login To Amazon`\n| EVA→IA\n| Amazon login flow.\n\n| `ONBOARDING__INIT_LOAD_EARNINGS_PROCESS`\n| `[ONBOARDING] Init Load Earnings Process`\n| EVA→IA\n| Start an earnings load.\n\n| `CONTRACTS__REFRESH`\n| `[CONTRACTS] Refresh page`\n| EVA→IA\n| Refresh the contracts page.\n\n| `GENERAL__SHOW_TOAST`\n| `[GENERAL] Show Toast`\n| EVA→IA\n| `{ type, message }` — `'success'` → success toast, otherwise error.\n|===\n\n=== Sizing\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Type | Value | Effect\n\n| `RESIZE_IFRAME_SIZE`\n| `[RESIZE] Resize Iframe Size`\n| Set height and width.\n\n| `RESIZE_IFRAME_WIDTH_SIZE`\n| `[RESIZE] Resize Iframe Width Size`\n| Set width.\n\n| `RESIZE_IFRAME_HEIGHT_SIZE`\n| `[RESIZE] Resize Iframe Height Size`\n| Set height.\n\n| `RESET_IFRAME_SIZE`\n| `[RESIZE] Reset Iframe Size`\n| Back to `100%`.\n\n| `FULL_SIZE_TOP_BAR`\n| `[RESIZE] Full Size Top Bar`\n| Expand the supplier top bar.\n\n| `REVERT_TOP_BAR_SIZE`\n| `[RESIZE] Revert Top Bar Size`\n| Restore it.\n|===\n\nResize payloads may carry `differentFrameSelector` to resize *another* frame.\n\n=== Iframe host selectors\n\n`shared/eva/const/eva-iframe-selector.enum.ts` — how the parent addresses a frame:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Member | Selector\n\n| `TOP_BAR` | `app-supplier-box-header-eva`\n| `TRACKER` | `app-tracker`\n| `ONBOARDING_DETAILS` | `app-supplier-onboarding-details`\n| `UNDERWRITING_BETA` | `app-supplier-underwriting-beta`\n| `SUPPLIER_CONTRACTS` | `app-supplier-contracts`\n| `CONTRACTS` | `app-contracts`\n| `EVENT_RULES` | `app-event-rules-2`\n| `FEATURE_FLAGS` | `app-admin-feature-flags`\n| `QA` | `app-admin-qa`\n|===\n\nCookie key: `EVA_COOKIE_TOKEN_KEY = 'PYBEvaToken'`\n(`shared/eva/const/eva.ts`).\n\nSee xref:platform:iframe-messaging.adoc[iframe messaging and EVA].\n\n[IMPORTANT]\n====\n`MESSAGE_TYPE` is a *cross-repository contract* shared with the EVA codebase. Adding a\ntype is safe (unknown types are ignored by the switch); changing or removing one\nrequires a coordinated release on both sides.\n====\n\n== 4. In-app event bus\n\n`{source-root}/shared/table-events.service.ts` — string-named `Subject` bus.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Event | Broadcast by → consumed by\n\n| `TASK_COUNTERS_UPDATE`\n| `CrmEffects` after a task mutation → `TaskCountBadgeComponent`.\n\n| `CRM_OPEN_ALL_TAB`\n| `CrmEffects` after adding a CRM item → the CRM panel switches to \"All\".\n\n| `ADD_TRANSACTION_EVENT`\n| Transaction dialogs → transaction grids refresh.\n\n| `SIDENAV_CLOSED_EVENT`\n| Sidebar → layout listeners.\n|===\n\n`on()` has no unsubscribe — register once. A throwing listener kills the shared\n`Subject` for everyone. See\nxref:platform:realtime.adoc#in-app-bus[the in-app event bus].\n\n=== Other `Subject` buses\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Service | Emits\n\n| `CancelEmitService`\n| `onCancellationChange: Subject<boolean>`,\n  `onWinbackChange: Subject<void>`,\n  `editOnCancellationChange: Subject<string>`.\n\n| `CommunicationService`\n| `incomingMessage$`, `featureFlagsFromEva$`.\n\n| `PendingInterceptorService`\n| `pendingRequestsStatus: Observable<boolean>`.\n\n| `CardFormgroupMessageService`\n| Card save/error feedback.\n\n| `B2bWorkflowService`\n| The shared SSE stream plus an internal `handler` subject.\n|===\n\n== Server-sent events\n\nOne stream: `GET <workflowApiUrl>tasksMonitoring`, opened by\n`B2bWorkflowService.taskMonitoring()` through `SseService`\n(`EventSourcePolyfill` with an `X-Session-Token` header). Messages are arbitrary JSON\ncarrying at least `taskName`.\n\nNo reconnect logic — any error or parse failure terminates the stream. See\nxref:platform:realtime.adoc[real-time streams].\n\n== CRM timeline item types\n\nNot events, but the adjacent vocabulary — the `type` discriminator in the\n`activity/` feed (`CrmService.listActions`):\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| `type` | Model\n\n| `note` | `NoteModel`\n| `task` | `TaskModel`\n| `event` | `EventModel`\n| `file` | `FileModel`\n| `email`, `email_supplier` | `EmailModel`\n| `batch` | `EmailModel`\n| *(anything else)* | `BaseCrmModel`\n|===\n\nSee xref:domains:crm.adoc[CRM].\n\n== Analytics events\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Event | Sent by\n\n| `WINDOW ERROR`\n| `window.onerror` in `src/main.ts` → Segment `track`.\n\n| `identify('KYC Secure Site Guardrails', …)`\n| `MonitorService.logEvent` → Segment.\n\n| `POST <eventLoggerUrl>track`\n| `MonitorService.logEvent` — `subject: 'Restricted UW Access'`,\n  `extra.eventTypeCode: 'kyc_secure_site_guardrails'`. Only for\n  `REST_UW_ACCESS` / `ADVANCE_AUDITORS` users.\n\n| `identify(loggedUser.name, …)`\n| `MonitorService.initFullStory` on login.\n\n| Arbitrary `track(message.subject, …)`\n| `MonitorService.sendSegmentEvent`.\n\n| `ErrorLogger.log(title, …)`\n| Segment `track`, optionally with a redacted store dump.\n|===\n\nSee xref:platform:observability.adoc[observability].\n\n== Misspellings that are load-bearing\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Item | Note\n\n| `analitics`\n| The event type *and* the `X-EventLogger-Type` header value.\n\n| `PREFIX_EVNET_RULE`\n| Constant name only; the value `em_event_rule_` is correct.\n\n| `superceded`, `cancelation_status`\n| Fields in the balance-approval API contract.\n|===\n"},{"component":"internal-admin","module":"reference","path":"reference:inventory.adoc","title":"Codebase inventory","description":"Counts and distribution of components, services, models and specs per module — a sense of scale and where the weight sits.","url":"https://docx.payability.com/internal-admin/reference/inventory.html","navOrder":58,"source":"= Codebase inventory\n:description: Counts and distribution of components, services, models and specs per module — a sense of scale and where the weight sits.\n\nSnapshot of the `{default-branch}` branch. Counts exclude `*.spec.ts` from the\ncomponent/service/model columns.\n\n== Totals\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Artefact | Count | Note\n\n| TypeScript files\n| 1455\n| Including specs.\n\n| TypeScript lines (excluding specs)\n| ~96,700\n| Application code only.\n\n| Components\n| 421\n| `*.component.ts`\n\n| Services\n| 150\n| `*.service.ts` — includes `*-mock.service.ts` and `*-abstract.service.ts`\n\n| Models\n| 80\n| `*.model.ts`\n\n| Directives\n| 18\n| `*.directive.ts`\n\n| Pipes\n| 12\n| `*.pipe.ts`\n\n| Enums\n| 21\n| `*.enum.ts` (more enums live inside other files)\n\n| Angular modules\n| 27\n| `*.module.ts`\n\n| Spec files\n| ~400\n| `*.spec.ts`\n\n| Templates\n| 407\n| `*.html`\n\n| Stylesheets\n| 279\n| `*.scss`\n\n| Guards\n| 4\n| `AuthGuard`, `FeatureFlagGuard`, `FeatureFlagsGeneralGuard`,\n  `SubPermissionsGuard` (+ `RouteAccessResolver`, which is a guard despite its name)\n\n| Resolvers\n| 2\n| `SupplierKeyResolver` and its mock\n\n| Store slices\n| 12\n| See xref:store-actions.adoc[the store action catalogue]\n\n| Effect classes\n| 6\n| Registered in `EffectsModule.forRoot`\n\n| Feature flags\n| 14\n| See xref:feature-flag-catalog.adoc[the flag catalogue]\n\n| Permissions\n| 40\n| See xref:permission-catalog.adoc[the permission catalogue]\n\n| Backend base URLs\n| ~19\n| See xref:environment-variables.adoc[environment keys]\n\n| Committed build residue\n| 284\n| Stale `*.js` files beside their `.ts` sources — **not inputs**\n|===\n\n[CAUTION]\n====\n284 committed `*.js` files (plus their `.js.map` siblings) sit next to `.ts` sources\nthroughout `{source-root}`. They are residue from an earlier toolchain. Always edit the\n`.ts`; exclude `*.js` from searches or you will get duplicate hits and read stale code.\n====\n\n== Per module\n\n[cols=\"2,1,1,1,1,1,1\",options=\"header\"]\n|===\n| Module | Components | Services | Models | Directives | Pipes | Specs\n\n| `suppliers`\n| 200\n| 68\n| 45\n| 5\n| 1\n| 197\n\n| `manage`\n| 79\n| 7\n| 3\n| 0\n| 0\n| 72\n\n| `shared`\n| 67\n| 50\n| 12\n| 12\n| 8\n| 65\n\n| `crm-tools`\n| 45\n| 15\n| 18\n| 1\n| 1\n| 38\n\n| `processes`\n| 12\n| 5\n| 0\n| 0\n| 0\n| 13\n\n| `reports`\n| 6\n| 3\n| 0\n| 0\n| 1\n| 6\n\n| `admin`\n| 4\n| 0\n| 0\n| 0\n| 0\n| 1\n\n| `auth`\n| 3\n| 0\n| 0\n| 0\n| 0\n| 3\n\n| `marketplaces`\n| 2\n| 2\n| 2\n| 0\n| 0\n| 3\n\n| `app-welcome`\n| 1\n| 0\n| 0\n| 0\n| 0\n| 1\n\n| `underwriting`\n| 1\n| 0\n| 0\n| 0\n| 0\n| 0\n\n| `emails`\n| 0\n| 0\n| 0\n| 0\n| 0\n| 0\n\n| `guards`\n| 0\n| 0\n| 0\n| 0\n| 0\n| 0\n\n| `mocks`\n| 0\n| 0\n| 0\n| 0\n| 1\n| 0\n\n| `pyb-material`\n| 0\n| 0\n| 0\n| 0\n| 0\n| 0\n\n| `store`\n| 0\n| 0\n| 0\n| 0\n| 0\n| 0\n|===\n\n== What the distribution tells you\n\nSuppliers is half the application::\n200 of 421 components and 68 of 150 services. Any change to supplier loading, the\npart-editing mechanism or `SupplierDetailsModel` has wide reach. See\nxref:domains:suppliers.adoc[suppliers].\n\nManage is second-largest but service-light::\n79 components against 7 services — the back-office screens are mostly forms over the\nglobal-settings and users endpoints rather than bespoke integrations. See\nxref:domains:manage.adoc[manage].\n\n`shared` carries a third of the services::\n50 services in `shared/` versus 67 components, and it owns all 12 directives-outside-\nsuppliers and 8 of the 12 pipes. It is a genuine library, not a dumping ground — but it\nalso means a change there is a broad change. See\nxref:platform:shared-library.adoc[shared library].\n\n`emails`, `guards`, `pyb-material`, `store` and `mocks` are structural::\n`emails/` is routes only (components live in `crm-tools/`); `guards/` holds two\nguards; `pyb-material/` re-exports Material; `store/` holds the reducer map;\n`mocks/` is test-only.\n\n`underwriting/` is one component::\nThe cross-supplier tracker, fully EVA-rendered. Per-supplier underwriting lives under\n`suppliers/supplier/supplier-underwriting/`. See\nxref:domains:underwriting-tracker.adoc[UW tracker].\n\nSpec counts track component counts::\n~400 specs for ~421 components. Coverage by *count* is high; coverage by *behaviour* is\nuneven — many are `should create` smoke tests. The substantive suites are the advance\ncalculator, feature flags, the card model and real-time validation. See\nxref:ops:testing.adoc[testing].\n\n`processes` has more specs than components::\n13 specs to 12 components — the batch and balance-approval logic is comparatively well\ncovered, which matters given it moves money.\n\n== Largest single files\n\nWorth knowing before opening them:\n\n[cols=\"3,1,3\",options=\"header\"]\n|===\n| File | Lines | Note\n\n| `suppliers/store/supplier/supplier.effects.ts`\n| ~920\n| The router-driven load, the aggregate `forkJoin`, and every supplier side effect.\n\n| `suppliers/store/supplier/supplier.actions.ts`\n| ~650\n| ~70 action types plus ~27 selectors.\n\n| `crm-tools/store/crm.effects.ts`\n| ~570\n| Timeline loading and all CRM mutations.\n\n| `…/pending-offer-calculator/store/calculator.spec.ts`\n| ~560\n| The most thorough spec in the repository.\n\n| `suppliers/store/supplier/supplier.reducers.ts`\n| ~510\n| Also where the reducer mutations live.\n\n| `…/pending-offer-calculator/store/calculator.reducers.ts`\n| ~430\n| The pricing arithmetic.\n\n| `crm-tools/store/crm.actions.ts`\n| ~280\n| Actions plus the stream-selector factory.\n\n| `…/pending-offer-calculator/store/calculator.actions.ts`\n| ~245\n| 30 action types.\n\n| `crm-tools/store/crm.reducers.ts`\n| ~220\n| The normalised timeline store.\n\n| `suppliers/model/supplier.model.ts`\n| ~310\n| `SupplierModel`, `SupplierDetailsModel`, `ISupplierDetailsDTO`, the address and\n  messenger models, and the derived getters.\n|===\n\n== Route surface\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Area | Approx. routes\n\n| `suppliers` (incl. ~25 tabs and their children)\n| ~55\n\n| `manage`\n| ~25\n\n| `processes` (two tables)\n| 10\n\n| `reports`\n| 7\n\n| `admin`\n| 4\n\n| `tasks`, `emails`, `marketplaces`, `underwriting`, `welcome`, auth, root\n| ~15\n|===\n\nFull table: xref:routes.adoc[the route catalogue].\n\n== Regenerating these numbers\n\n[source,bash]\n----\n# per-artefact totals\nfind src/app -name '*.component.ts' ! -name '*.spec.ts' | wc -l\nfind src/app -name '*.service.ts'   ! -name '*.spec.ts' | wc -l\nfind src/app -name '*.model.ts' | wc -l\nfind src/app -name '*.spec.ts'  | wc -l\n\n# per module\nfor d in src/app/*/; do\n  printf '%-18s %s\\n' \"$(basename \"$d\")\" \\\n    \"$(find \"$d\" -name '*.component.ts' ! -name '*.spec.ts' | wc -l)\"\ndone\n\n# application lines, excluding specs\nfind src/app -name '*.ts' ! -name '*.spec.ts' -exec cat {} + | wc -l\n\n# the stale build residue\nfind src/app -name '*.js' | wc -l\n----\n\nUpdate this page when a module's shape changes materially — see the blast-radius\nchecklist in the `docs-writer` skill.\n"},{"component":"morpheus","module":"ROOT","path":"index.adoc","title":"Morpheus","description":"Automated outbound sales platform for Amazon and Walmart marketplace sellers, built on AWS.","url":"https://docx.payability.com/morpheus/index.html","navOrder":0,"source":"= Morpheus\n:description: Automated outbound sales platform for Amazon and Walmart marketplace sellers, built on AWS.\n:keywords: morpheus, agentcore, bedrock, hubspot, outbound, sales automation\n\nMorpheus is Payability's automated outbound sales platform. It finds marketplace\nsellers who are likely to need faster access to their payouts, enriches them with\npublicly available contact intelligence, scores how acutely they feel payout pain,\npushes them into HubSpot, and then keeps a human sales team supplied with a\nprioritised daily call list built from real engagement signals.\n\nThe platform is event-driven and serverless. Nothing runs on a server that waits\nfor requests: everything is either a scheduled job, a queue consumer, or a\ncontainer invoked on demand.\n\n[TIP]\n====\nNew here? Read xref:architecture.adoc[System architecture] for the shape of the\nsystem, then xref:business:index.adoc[Business logic] for the rules the system\nactually enforces.\n====\n\n== What the platform does\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Capability | Where it lives\n\n| Discover sellers from marketplace data\n| xref:lambdas:enrichment-scraper.adoc[`enrichment-scraper`] via the SmartScout API\n\n| Enrich sellers with contacts, website, LinkedIn\n| xref:lambdas:enrichment-scorer.adoc[`enrichment-scorer`] — Claude on Bedrock with Tavily + HubSpot MCP tools\n\n| Score cash-flow urgency (payout-pain score)\n| xref:business:payout-pain-score.adoc[Second AI pass] in `enrichment-scorer`\n\n| Push companies, contacts, activities, tasks, deals into HubSpot\n| xref:lambdas:morpheus-hubspot.adoc[`morpheus-hubspot`] — the only writer to the CRM\n\n| Rebuild each rep's daily call list from engagement signals\n| xref:business:re-engagement.adoc[Re-engagement handlers] driven by BigQuery views\n\n| Classify inbound email replies\n| xref:lambdas:email-agent.adoc[`email-agent`] — Bedrock structured output\n\n| Line-type intelligence and Slack task escalation\n| xref:lambdas:phone-agent.adoc[`phone-agent`] — Twilio Lookup, Slack, SQS\n\n| Multi-step LLM workflows on demand\n| xref:agentcore:index.adoc[AgentCore runtime] — Strands agents in a container\n|===\n\n== Deployable units\n\nThe repository holds four independently deployed units. They never import each\nother at runtime — the only coupling is through SQS messages, the shared\nPostgreSQL database, and the shared Secrets Manager secret.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| Unit | Language | Deployed by | Purpose\n\n| `agentcore/`\n| Python {python-version}\n| CDK (`make deploy-cdk`)\n| Container image running on Amazon Bedrock AgentCore — the on-demand LLM workflow runtime\n\n| `cdk/`\n| TypeScript\n| CDK (`make deploy-cdk`)\n| All AgentCore infrastructure — one stack, `{cdk-stack}`\n\n| `lambdas/`\n| Python {python-version}\n| SAM (`make deploy-lambdas`)\n| Five Lambda functions, four queues, the schedules, and the shared secret\n\n| `libs/`\n| Python {python-version}\n| SAM (as Lambda layers)\n| Five shared libraries consumed by the Lambdas and the runtime\n|===\n\n== The pipeline at a glance\n\n[mermaid]\n....\nflowchart LR\n  SS([\"SmartScout API\"])\n  BQ[(\"BigQuery\\nHubSpot + app mirror\")]\n\n  subgraph ingest [\"Ingest and enrich\"]\n    Scraper[\"λ enrichment-scraper\"]\n    Q1[[\"EnrichmentPipelineQueue\\nFIFO\"]]\n    Scorer[\"λ enrichment-scorer\"]\n  end\n\n  subgraph crm [\"CRM integration\"]\n    Q2[[\"CrmSyncQueue\\nFIFO\"]]\n    HubSpotFn[\"λ morpheus-hubspot\"]\n    HS([\"HubSpot CRM\"])\n  end\n\n  subgraph channels [\"Channel agents\"]\n    Q3[[\"EmailAgentQueue\"]]\n    EmailFn[\"λ email-agent\"]\n    PhoneFn[\"λ phone-agent\"]\n    Slack([\"Slack\"])\n  end\n\n  PG[(\"PostgreSQL\\nSupabase\")]\n  Bedrock([\"Amazon Bedrock\\nClaude / Nova\"])\n\n  SS --> Scraper --> Q1 --> Scorer\n  Scorer --> PG\n  Scorer <--> Bedrock\n  Scorer --> Q2 --> HubSpotFn --> HS\n  HubSpotFn --> PG\n  BQ --> HubSpotFn\n  BQ --> Scraper\n  BQ --> PhoneFn\n  PhoneFn --> Q2\n  PhoneFn --> Q3\n  PhoneFn --> Slack\n  Q3 --> EmailFn --> PG\n  EmailFn <--> Bedrock\n  HS -.->|webhooks| Q2\n....\n\nRead the full walkthrough in xref:business:funnel.adoc[Outbound funnel].\n\n== Documentation map\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Section | Contents\n\n| xref:business:index.adoc[Business logic]\n| Every domain rule the platform enforces: discovery filters, enrichment, scoring, re-engagement segments, priority formulas, ownership, escalation\n\n| xref:agentcore:index.adoc[AgentCore runtime]\n| The Bedrock AgentCore container: workflow registry, agents, tools, memory, invocation contract\n\n| xref:lambdas:index.adoc[Lambda functions]\n| One page per function — triggers, handlers, configuration, failure behaviour\n\n| xref:events:index.adoc[Events]\n| The complete event catalog, payload schemas, routing keys, and how to add one\n\n| xref:libs:index.adoc[Shared libraries]\n| `morpheus-common`, `morpheus-llm`, `morpheus-data-layer`, `morpheus-bigquery`, `pyb-hubspot`\n\n| xref:data:index.adoc[Data]\n| Domain model, ORM models, repositories, enums, BigQuery warehouse and views\n\n| xref:infrastructure:index.adoc[Infrastructure]\n| CDK stack, SAM template, queues, schedules, layers, IAM, secrets, observability\n\n| xref:plans:index.adoc[Roadmap and plans]\n| Forward-looking design documents for the agent estate and the BDR motion\n|===\n\n== Environment facts\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Fact | Value\n\n| AWS account | `{aws-account}`\n| AWS region | `{aws-region}`\n| CDK stack | `{cdk-stack}`\n| SAM stack | `{sam-stack}`\n| Shared secret | `{shared-secret}` (Secrets Manager)\n| Operating subscription (tenant) | `{subscription-id}`\n| HubSpot portal | `{hubspot-portal}`\n| BigQuery project | `{bq-project}`\n|===\n\nBedrock models with the `us.` prefix are invoked through cross-region inference\nprofiles. See xref:libs:morpheus-llm.adoc#models[model IDs].\n"},{"component":"morpheus","module":"ROOT","path":"architecture.adoc","title":"System architecture","description":"How the Morpheus deployable units, queues, schedules, and data stores fit together.","url":"https://docx.payability.com/morpheus/architecture.html","navOrder":1,"source":"= System architecture\n:description: How the Morpheus deployable units, queues, schedules, and data stores fit together.\n\nMorpheus is a set of independent, event-driven components joined by three\nintegration points: SQS queues, a shared PostgreSQL database, and one Secrets\nManager secret. No component calls another component's code.\n\n== Architectural principles\n\nSingle writer to the CRM::\nOnly xref:lambdas:morpheus-hubspot.adoc[`morpheus-hubspot`] writes to HubSpot.\nEvery other component that needs a CRM change publishes an event to\n`CrmSyncQueue`. This keeps HubSpot rate-limit handling, OAuth, and property\nmapping in one place.\n\nRegistry-based dispatch::\nEvery Lambda is a thin dispatcher over a registry that maps a routing key —\nan SQS `event_type` message attribute or an EventBridge `detail-type` — to a\nhandler class and a Pydantic model. Adding behaviour means adding a handler and\none registry entry, never editing the dispatcher. See\nxref:lambdas:handler-pattern.adoc[Handler and registry pattern].\n\nFat events::\nMessages carry everything the consumer needs. `Seller.CrmSync` embeds the\ncompany fields and the full contact list so `morpheus-hubspot` performs no\ndatabase read just to sync. See xref:events:index.adoc[Events].\n\nValidate at the boundary::\nEvery inbound payload is parsed by a Pydantic v2 model before a handler sees it.\nMalformed payloads are logged and dropped rather than retried forever.\n\nBest-effort AI, durable persistence::\nDatabase writes commit first; LLM enrichment and scoring run afterwards inside\n`try`/`except`. An AI failure never causes a queue message to be retried and\nnever rolls back a committed row.\n\nConfiguration by secret, not by environment::\nCredentials live in one Secrets Manager secret (`{shared-secret}`) fetched once\nper container and cached. Environment variables carry only infrastructure facts\n(queue URLs, ARNs, tenant id, batch sizes).\n\nNo business logic in infrastructure code::\nNeither the CDK stack nor the SAM template chooses models, prompts, or providers.\nThey provision resources and inject wiring.\n\n== Component topology\n\n[mermaid]\n....\nflowchart TB\n  subgraph ext [\"External systems\"]\n    SS([\"SmartScout API\"])\n    HS([\"HubSpot CRM\"])\n    TAV([\"Tavily MCP\"])\n    TW([\"Twilio Lookup v2\"])\n    SL([\"Slack\"])\n    FC([\"Files.com\\nFive9 CSV drops\"])\n    RS([\"Resend\"])\n  end\n\n  subgraph aws [\"AWS account 625663093496 / us-east-1\"]\n    subgraph sam [\"SAM stack — lambdas/template.yaml\"]\n      Scraper[\"enrichment-scraper\"]\n      Scorer[\"enrichment-scorer\"]\n      HubFn[\"morpheus-hubspot\"]\n      EmailFn[\"email-agent\"]\n      PhoneFn[\"phone-agent\"]\n      Q1[[\"EnrichmentPipelineQueue.fifo\"]]\n      Q2[[\"CrmSyncQueue.fifo\"]]\n      Q3[[\"EmailAgentQueue\"]]\n      DLQ[[\"3 dead-letter queues\"]]\n      SM([\"Secrets Manager\\nagentcore-configs\"])\n    end\n\n    subgraph cdkstack [\"CDK stack — AgentCoreStack\"]\n      RT[\"Bedrock AgentCore Runtime\\nagentcore container\"]\n      MEM[(\"AgentCore Memory\")]\n      ECR[(\"ECR image\")]\n      LG[(\"CloudWatch log group\")]\n    end\n\n    BR([\"Amazon Bedrock\"])\n  end\n\n  PG[(\"PostgreSQL — Supabase\\nshared with the web app\")]\n  BQ[(\"BigQuery\\nhubspot4 + outreach_app\")]\n\n  SS --> Scraper\n  Scraper --> Q1 --> Scorer\n  Scraper --> PG\n  BQ --> Scraper\n  Scorer --> PG\n  Scorer --> Q2\n  Scorer --> TAV\n  Scorer --> BR\n  Q2 --> HubFn\n  HubFn --> HS\n  HubFn --> PG\n  BQ --> HubFn\n  FC --> HubFn\n  HS -. webhook relay .-> Q2\n  PhoneFn --> TW\n  PhoneFn --> SL\n  PhoneFn --> Q2\n  PhoneFn --> Q3\n  PhoneFn --> PG\n  BQ --> PhoneFn\n  RS -. inbound relay .-> Q3\n  Q3 --> EmailFn --> PG\n  EmailFn --> BR\n  Q1 -.-> DLQ\n  Q2 -.-> DLQ\n  Q3 -.-> DLQ\n  SM --- Scraper & Scorer & HubFn & EmailFn & PhoneFn & RT\n  ECR --> RT\n  RT --- MEM\n  RT --> BR\n  RT --> LG\n....\n\n== Data planes\n\nThere are two data planes, and it matters which one a given rule reads from.\n\nOLTP — PostgreSQL (Supabase)::\nThe operational database, shared with the Payability web application. Morpheus\nowns a subset of tables through the\nxref:libs:morpheus-data-layer.adoc[`morpheus-data-layer`] ORM: `sellers`,\n`contacts`, `target_emails`, `target_phone_numbers`, `Call`, `CampaignTarget`,\n`Activity`, `follow_up_tasks`, `sales_reps`, `configs`, and the email campaign\ntables. Schema is owned by the web application's Prisma schema — see\nxref:data:prisma-schema.adoc[Prisma schema reference].\n\nOLAP — BigQuery::\nA Stitch export of the HubSpot portal (`{bq-project}.{bq-dataset-hubspot}`) plus\na mirror of application tables (`{bq-project}.{bq-dataset-app}`). All\nre-engagement segmentation and scoring SQL runs here because it needs a full\ncross-object view of the CRM (deals, engagements, contacts) that PostgreSQL does\nnot hold. See xref:data:warehouse.adoc[BigQuery warehouse].\n\n[IMPORTANT]\n====\nRe-engagement handlers *read* from BigQuery and *write* to HubSpot. The\nPostgreSQL row is consulted only to resolve identifiers and ownership. BigQuery\ndata is a periodic export, so a segment can lag reality by the export interval.\n====\n\n== Message flows\n\n=== Discovery to CRM\n\n[mermaid]\n....\nsequenceDiagram\n  participant EB as EventBridge\n  participant SC as enrichment-scraper\n  participant SS as SmartScout\n  participant Q1 as EnrichmentPipelineQueue\n  participant SR as enrichment-scorer\n  participant DB as PostgreSQL\n  participant BR as Bedrock\n  participant Q2 as CrmSyncQueue\n  participant HF as morpheus-hubspot\n  participant HS as HubSpot\n\n  EB->>SC: SellerDiscovery.Amazon\n  SC->>DB: read pagination cursor (configs)\n  SC->>SS: paginated seller search\n  SS-->>SC: seller rows\n  SC->>DB: save next_start_row\n  SC->>Q1: SellerRecord.Received (batch of 10)\n  Q1->>SR: one message per invocation\n  SR->>DB: upsert seller (commits first)\n  SR->>BR: enrichment agent (Tavily + HubSpot MCP)\n  SR->>DB: apply enrichment + upsert contacts\n  SR->>BR: payout-pain scoring agent\n  SR->>DB: write payoutPainScore\n  SR->>Q2: Seller.CrmSync (fat event)\n  Q2->>HF: dispatch by event_type\n  HF->>HS: upsert Company, upsert Contacts, log Notes\n  HF->>DB: write back hubspotCompanyId / hubspotContactId\n....\n\n=== Daily call-list refresh\n\n[mermaid]\n....\nsequenceDiagram\n  participant EB as EventBridge\n  participant HF as morpheus-hubspot\n  participant BQ as BigQuery\n  participant DB as PostgreSQL\n  participant HS as HubSpot\n\n  EB->>HF: Hubspot.EmailClickReEngagement (and 3 sibling segments)\n  HF->>BQ: SELECT * FROM v_*_reeng\n  BQ-->>HF: candidate rows + priority_score\n  HF->>HF: drop rows called today / recently by a human\n  HF->>DB: resolve contact, check owner is an active rep\n  HF->>DB: round-robin next rep (advisory lock + configs cursor)\n  HF->>HS: PATCH call_list_date, call_priority, call_assigned_to, outreach_list_reason\n  EB->>HF: Hubspot.SalesRepCallListRefresh (daily)\n  HF->>HS: delete yesterday's list, create today's dynamic list per rep\n  HF->>DB: store hubspotCallSegmentId\n....\n\nSee xref:business:call-lists.adoc[Daily call lists] for the rules and\nxref:business:re-engagement.adoc[Re-engagement segments] for each segment.\n\n== Failure and retry model\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Queue | `maxReceiveCount` | Behaviour on handler failure\n\n| `EnrichmentPipelineQueue.fifo`\n| 1\n| Straight to `EnrichmentPipelineDlq.fifo`, retained 14 days. No automatic retry.\n\n| `CrmSyncQueue.fifo`\n| 1\n| Straight to `CrmSyncDlq.fifo`, retained 14 days. No automatic retry.\n\n| `EmailAgentQueue`\n| 1\n| Straight to `EmailAgentDlq`, retained 14 days.\n|===\n\nAll three SQS triggers declare `ReportBatchItemFailures`, so a failure isolates\nto the offending message rather than the batch. Batch size is 1 everywhere\ntoday, which makes the distinction academic but keeps the contract correct if\nbatch size is raised.\n\nHandlers distinguish two failure classes deliberately:\n\n* *Bad data* — unknown `event_type`, subscription mismatch, missing row,\n  malformed UUID. Logged at `ERROR` and the handler returns normally, so the\n  message is deleted. Retrying cannot help.\n* *Transient failure* — Bedrock error, HubSpot 5xx, database error. The\n  exception propagates so the message is recorded as a batch item failure.\n\nScheduled (EventBridge) handlers have no queue behind them. A failed run simply\nwaits for the next tick; most iterate per-row with `try`/`except` so one bad row\ndoes not abort the batch.\n\n== Where to go next\n\n* xref:repository-layout.adoc[Repository layout] — what lives where on disk\n* xref:configuration.adoc[Configuration and secrets] — every setting, per unit\n* xref:infrastructure:index.adoc[Infrastructure] — the provisioned resources\n* xref:operations.adoc[Operations and runbooks] — logs, DLQ replay, watermarks\n"},{"component":"morpheus","module":"ROOT","path":"repository-layout.adoc","title":"Repository layout","description":"What lives where in the agentcore monorepo.","url":"https://docx.payability.com/morpheus/repository-layout.html","navOrder":2,"source":"= Repository layout\n:description: What lives where in the agentcore monorepo.\n\n== Top level\n\n[source]\n----\nagentcore/       Bedrock AgentCore container runtime (Python)\ncdk/             AWS CDK app — one stack, AgentCoreStack (TypeScript)\nlambdas/         SAM application — 5 functions, 3 queues, schedules, secret (Python)\nlibs/            Shared Python libraries, packaged as Lambda layers\nbigquery/        BigQuery view definitions (DDL, applied manually)\nscripts/         Ad-hoc operator scripts\ndocs/            This Antora documentation component\n.specs/          Historical implementation plans, one per feature\n.github/         Security-review workflow and its agent prompt\nAGENTS.md        Authoritative conventions for AI coding agents\nMakefile         Top-level build and deploy shortcuts\n----\n\n== `agentcore/` — the runtime\n\n[source]\n----\nagentcore/\n├── main.py                       load_config() → build_registry() → app.run()\n├── constants.py                  env-var-driven constants (LOG_GROUP_NAME, MEMORY_ID, …)\n├── Dockerfile                    linux/arm64 image, PYTHONPATH=/app\n├── config/loader.py              Secrets Manager fetch + Pydantic validation + cache\n├── providers/\n│   ├── base_service.py           re-export of morpheus_llm BaseAgentService\n│   ├── claude_service.py         re-export of morpheus_llm ClaudeAgentService\n│   └── agents/dialler_agent.py   pre-wired agent singleton\n├── tools/builtin/                local @tool functions\n├── types/__init__.py             WorkflowStep, WorkflowResult, InvocationPayload\n├── utils/enums.py                re-export of AgentModelIds\n├── workflows/\n│   ├── __init__.py               build_registry()\n│   ├── registry.py               WorkflowRegistry + WorkflowDefinition protocol\n│   ├── base_workflow.py          multi-step execution engine\n│   └── builtin/                  PromptWorkflow, DiallerAgentWorkflow\n└── test/test_main.py\n----\n\nDetails: xref:agentcore:index.adoc[AgentCore runtime].\n\n== `lambdas/` — the SAM application\n\nEvery function follows the same internal shape:\n\n[source]\n----\nlambdas/<function>/\n├── handler.py        lambda_handler — dispatcher only, no business logic\n├── registry.py       routing key → (handler class, Pydantic model)\n├── events/           one module per event type; handlers extend a Base*Handler\n│   └── samples/      example payloads used for `sam local invoke` and tests\n├── models/\n│   ├── config.py     pydantic-settings Settings — environment variables\n│   ├── secrets.py    load_secrets() — @lru_cache over the shared secret\n│   └── events.py     event payload models local to this function\n├── services/         integrations and orchestration used by handlers\n├── utils/            constants, logging setup, pure helpers\n├── pyproject.toml    dependencies (uv)\n└── requirements.txt  compiled dependency list used by the SAM build\n----\n\nThe five functions:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Directory | Page\n\n| `enrichment_scraper/` | xref:lambdas:enrichment-scraper.adoc[enrichment-scraper]\n| `enrichment_scorer/`  | xref:lambdas:enrichment-scorer.adoc[enrichment-scorer]\n| `morpheus_hubspot/`   | xref:lambdas:morpheus-hubspot.adoc[morpheus-hubspot]\n| `email_agent/`        | xref:lambdas:email-agent.adoc[email-agent]\n| `phone_agent/`        | xref:lambdas:phone-agent.adoc[phone-agent]\n|===\n\n`lambdas/template.yaml` is the single SAM template holding every function,\nlayer, queue, schedule, alarm, and the shared secret. See\nxref:infrastructure:sam-template.adoc[SAM template].\n\n== `libs/` — shared libraries\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Directory | Import prefix | Purpose\n\n| `morpheus-common/`     | `morpheus_common`     | Event models, SQS publisher, secrets helper, Sentry\n| `morpheus-llm/`        | `morpheus_llm`        | Strands agent services, Bedrock, MCP tools, model IDs\n| `morpheus-data-layer/` | `morpheus_data_layer` | SQLAlchemy models, repositories, session management\n| `morpheus-bigquery/`   | `morpheus_bigquery`   | BigQuery client factory\n| `pyb-hubspot/`         | `pyb_hubspot`         | HubSpot CRM client and every HubSpot API call\n|===\n\nDetails: xref:libs:index.adoc[Shared libraries].\n\n== `cdk/`\n\n[source]\n----\ncdk/\n├── bin/cdk.ts                      instantiates AgentCoreStack — configuration only\n└── lib/\n    ├── types.ts                    AgentCoreStackProps\n    └── stacks/agentcore-stack.ts   every AWS resource for the runtime\n----\n\nThere is exactly one stack. Details: xref:infrastructure:cdk-stack.adoc[CDK stack].\n\n== `bigquery/`\n\nOne `.sql` file per view, each containing a `CREATE OR REPLACE VIEW` statement\nagainst `{bq-project}`. These are applied by hand — there is no migration\nrunner. See xref:data:bigquery-views.adoc[BigQuery views].\n\n== `docs/`\n\nThis Antora component.\n\n[source]\n----\ndocs/\n├── antora.yml                  component descriptor (name: morpheus, version: ~)\n└── modules/\n    ├── ROOT/                   platform-wide guides\n    ├── business/               domain and business rules\n    ├── agentcore/              the container runtime\n    ├── lambdas/                one page per function\n    ├── events/                 event catalog and schemas\n    ├── libs/                   shared library reference\n    ├── data/                   domain model, ORM, warehouse\n    ├── infrastructure/         CDK, SAM, queues, IAM, observability\n    └── plans/                  forward-looking design documents\n----\n\nSee xref:docs-contributing.adoc[Writing these docs].\n"},{"component":"morpheus","module":"ROOT","path":"glossary.adoc","title":"Glossary","description":"Domain and platform terms used throughout the Morpheus documentation.","url":"https://docx.payability.com/morpheus/glossary.html","navOrder":3,"source":"= Glossary\n:description: Domain and platform terms used throughout the Morpheus documentation.\n\n== Domain terms\n\nAgentCore Memory::\nManaged Bedrock service that persists agent conversation turns and extracts\nlong-term memory records. Morpheus configures three strategies — facts, contact\ndetails, and session summaries. See xref:agentcore:memory.adoc[AgentCore Memory].\n\nASIN::\nAmazon Standard Identification Number — one product listing. `numberAsins` on a\nseller is a proxy for catalogue size and therefore for inventory funding needs.\n\nCall list::\nThe set of contacts a sales rep should call today. Materialised as a dynamic\nHubSpot Contacts list per rep, filtered on `hubspot_owner_id` and\n`call_list_date`. See xref:business:call-lists.adoc[Daily call lists].\n\n`call_priority`::\nHubSpot contact property, `1`–`4`, where 4 is most urgent. Reps work top-down.\n\n`call_list_date`::\nHubSpot contact property holding the date (US Eastern, `YYYY-MM-DD`) the contact\nwas enrolled on a call list. Stamping today's date is what puts a contact on\ntoday's list.\n\n`call_assigned_to`::\nHubSpot contact property carrying the owner id the call is routed to. Distinct\nfrom `hubspot_owner_id`, which is the record's actual owner.\n\nCampaignTarget::\nOne phone number enrolled in a calling campaign, with its own state machine\n(`pending` → `dialing` → `completed`/`failed`/`skipped`, plus `DNC`, `WN`, `MAX`,\n`DT_PENDING`, `VOICEMAIL`).\n\nContact::\nA person. The single source of truth shared between campaign targets and HubSpot.\nUnique per subscription on `email`, `phone`, `hubspotContactId`, and\n`supplierKey`.\n\nDisposition::\nThe outcome of a call. Internally a string; in HubSpot a\n`hs_call_disposition` outcome GUID. Mappings live in\n`pyb_hubspot/disposition_outcomes.py` (AI calls) and\n`morpheus_hubspot/utils/constants.py` (Five9 calls). See\nxref:business:call-logging.adoc[Call logging and dispositions].\n\nFive9::\nThe BDR team's dialer. Call logs arrive as CSV drops on Files.com and are synced\ninto `Call` rows plus HubSpot call engagements.\n\nFollowUpTask::\nAn internal task assigned to a sales rep, mirrored into HubSpot (and optionally\nNooks). Carries an `escalationStage` counter used by the Slack escalation ladder.\nSee xref:business:follow-up-tasks.adoc[Follow-up tasks and escalation].\n\nICP::\nIdeal Customer Profile. Here: annual revenue between `ICP_REVENUE_MIN` and\n`ICP_REVENUE_MAX` (default \\$100k–\\$1M), optionally narrowed by category.\n\nLifecycle stage::\nTwo different ladders. `Seller.lifeCycleStage` is the internal enrichment\npipeline stage (`new` → `enriched` → …). `Contact.lifecycleStage` mirrors\nHubSpot's `lifecyclestage`. Promotion to `marketingqualifiedlead` is\nrank-guarded — it never demotes.\n\nMCP::\nModel Context Protocol. Morpheus consumes two remote MCP servers — HubSpot\n(`mcp.hubspot.com`) and Tavily (`mcp.tavily.com`) — as agent tools.\n\nNooks::\nSequencing and dialer platform. Optional integration used by the\ndialer-call-ended sync to enrol contacts in sequences.\n\n`outreach_list_reason`::\nHubSpot contact property recording which automation enrolled the contact on\ntoday's call list, so reps and reporting can see the segment source.\n\nPayout-pain score::\n`sellers.payoutPainScore`, 0–100. How acutely a seller feels cash-flow pressure\nfrom marketplace payout delays. Produced by a second, tool-free AI pass. See\nxref:business:payout-pain-score.adoc[Payout-pain score].\n\nRe-engagement::\nPutting a previously contacted person back on a call list because a fresh signal\nappeared. Five segments exist. See\nxref:business:re-engagement.adoc[Re-engagement segments].\n\nRound-robin::\nFair distribution of ownerless contacts across active sales reps, using a cursor\nin `configs` guarded by a PostgreSQL advisory transaction lock. See\nxref:business:ownership.adoc[Ownership and round-robin].\n\nSeller::\nA marketplace business (Amazon, Walmart, or Shopify). Maps to a HubSpot\n*Company*, not a Contact. Keyed by `sellerId`, the external marketplace id.\n\nSmartScout::\nThird-party Amazon marketplace intelligence API — the source of seller discovery.\n\nSubscription::\nThe tenant. Every domain row carries `subscriptionId`. Morpheus operates a single\nsubscription in production: `{subscription-id}`.\n\nSupplier::\nA seller who has created a Payability account. Tracked on `Contact` via\n`supplierKey`, `supplierStatus`, `supplierCancellationStatus`,\n`supplierMarketplace`.\n\nTavily::\nRemote MCP web-search and content-extraction service used by the enrichment\nagent.\n\nWatermark::\nA timestamp cursor in the `configs` table marking how far an incremental sync has\nprogressed. See xref:data:configs-table.adoc[Runtime state in `configs`].\n\n== Platform terms\n\nBest-effort step::\nA pipeline step wrapped in `try`/`except` that logs and returns on failure,\nbecause an earlier step already committed. Applies to AI enrichment, AI scoring,\nand CRM publishing in the enrichment pipeline.\n\nDispatcher::\n`handler.py` in each Lambda. Detects the event shape (SQS batch or EventBridge),\nresolves the routing key, validates the payload against the registered model, and\ncalls the handler. Contains no business logic.\n\nFat event::\nAn event carrying every field the consumer needs, so the consumer performs no\ndatabase read purely to do its job. `Seller.CrmSync` is the canonical example.\n\nRegistry::\nThe `dict` mapping routing keys to `(handler, model)` pairs in each Lambda's\n`registry.py`. Two registries exist in `morpheus-hubspot`: `event_registry` for\nSQS and `scheduled_registry` for EventBridge.\n\nRouting key::\nThe `event_type` SQS message attribute (or `event_type` field in the body for\n`CrmSyncQueue`), or the EventBridge `detail-type`. Must match a registry key\nexactly.\n\nStructured output::\nA Strands feature: setting `AgentConfig.structured_output_model` adds a dedicated\noutput tool so the model returns a validated Pydantic instance in\n`AgentResult.structured_output`. Removes all manual JSON parsing.\n\nWorkflow::\nA named, registered unit of work in the AgentCore runtime, invoked by name with a\npayload. Callers never name a model or an agent. See\nxref:agentcore:workflows.adoc[Workflows].\n"},{"component":"morpheus","module":"ROOT","path":"configuration.adoc","title":"Configuration and secrets","description":"Every environment variable and secret key used by Morpheus, per deployable unit.","url":"https://docx.payability.com/morpheus/configuration.html","navOrder":4,"source":"= Configuration and secrets\n:description: Every environment variable and secret key used by Morpheus, per deployable unit.\n\nConfiguration is split deliberately:\n\n* *Secrets* — credentials only. One Secrets Manager secret, `{shared-secret}`,\n  read once per container and cached.\n* *Environment variables* — infrastructure wiring only: queue URLs, ARNs,\n  tenant id, batch sizes, log level. Set by the SAM template or the CDK stack.\n\n[IMPORTANT]\n====\nAgent configuration — model id, system prompt, provider, token limits — is never\nan environment variable and never lives in CDK or SAM. It is code, in\n`agents/` modules and `AgentConfig` objects.\n====\n\n[#secret]\n== The shared secret\n\nSecrets Manager secret name `{shared-secret}` (created by the SAM template as\n`AgentcoreSecret`, populated manually after the first deploy). It is a flat JSON\nobject; each consumer maps only the keys it needs and ignores the rest.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Key | Consumers | Purpose\n\n| `DATABASE_URL`\n| all Lambdas\n| PostgreSQL connection string with the `postgresql://` scheme; the data layer always connects through psycopg2. Use the Supabase *Transaction mode* pooler URL (port 6543) — see xref:libs:morpheus-data-layer.adoc#pooling[pooling].\n\n| `SMARTSCOUT_USERNAME`, `SMARTSCOUT_PASSWORD`\n| scraper\n| SmartScout account credentials used to obtain a session token.\n\n| `SMARTSCOUT_TOKEN`, `SMARTSCOUT_TOKEN_EXPIRES_AT`\n| scraper\n| Cached SmartScout session token. Written back to the secret by the scraper's auth module — this is why the scraper has `secretsmanager:PutSecretValue`.\n\n| `HUBSPOT_PRIVATE_APP_TOKEN`\n| morpheus-hubspot\n| Preferred HubSpot auth. A `pat-…` Private App token; no refresh needed.\n\n| `HUBSPOT_CLIENT_ID`, `HUBSPOT_CLIENT_SECRET`, `HUBSPOT_REFRESH_TOKEN`\n| morpheus-hubspot, scorer, agentcore\n| HubSpot OAuth fallback, and the credentials the HubSpot MCP tool needs.\n\n| `HUBSPOT_ENVIRONMENT`\n| morpheus-hubspot\n| `production` or `preview` — selects the call-disposition outcome GUID set.\n\n| `TAVILY_API_KEY`\n| scorer, agentcore\n| Tavily Remote MCP web-search key used by the enrichment agent.\n\n| `OPENAI_API_KEY`\n| agentcore\n| Required by the runtime's secret schema. Reserved for a future OpenAI provider.\n\n| `ANTHROPIC_API_KEY` (stored as `CLAUDE_API_KEY`)\n| agentcore\n| Optional. Not needed on Bedrock — the IAM role provides authentication.\n\n| `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`\n| phone-agent\n| Twilio Lookup v2 line-type intelligence.\n\n| `SLACK_BOT_TOKEN`\n| phone-agent\n| Slack bot token for task reminders, DMs, and user lookup. Empty disables Slack work.\n\n| `BIGQUERY_CREDENTIALS`\n| scraper, morpheus-hubspot, phone-agent\n| Google service-account JSON, as a string. Empty makes every BigQuery-backed handler log a warning and return.\n\n| `NOOKS_API_KEY`, `NOOKS_DEFAULT_SEQUENCE_ID`, `NOOKS_DEFAULT_MAILBOX_ID`\n| morpheus-hubspot\n| Optional. Nooks Sequencing API, used by the dialer-call-ended sync.\n\n| `FILES_COM_API_KEY`\n| morpheus-hubspot\n| Files.com key used to download Five9 call-log CSVs.\n|===\n\n[NOTE]\n====\nContainers cache the secret for their lifetime (`@lru_cache` in Lambdas, a module\nglobal in the runtime). A rotated value takes effect on the next cold start, not\nimmediately. Force a cold start by updating the function configuration or\nredeploying.\n====\n\n=== Populating the secret\n\n[source,bash,subs=attributes+]\n----\naws secretsmanager put-secret-value \\\n  --secret-id {shared-secret} \\\n  --region {aws-region} \\\n  --secret-string '{\n    \"DATABASE_URL\": \"postgresql://…:6543/postgres\",\n    \"HUBSPOT_PRIVATE_APP_TOKEN\": \"pat-na1-…\",\n    \"HUBSPOT_CLIENT_ID\": \"…\",\n    \"HUBSPOT_CLIENT_SECRET\": \"…\",\n    \"HUBSPOT_REFRESH_TOKEN\": \"…\",\n    \"TAVILY_API_KEY\": \"tvly-…\",\n    \"OPENAI_API_KEY\": \"sk-…\",\n    \"TWILIO_ACCOUNT_SID\": \"AC…\",\n    \"TWILIO_AUTH_TOKEN\": \"…\",\n    \"SLACK_BOT_TOKEN\": \"xoxb-…\",\n    \"BIGQUERY_CREDENTIALS\": \"{\\\"type\\\":\\\"service_account\\\", …}\"\n  }'\n----\n\nNever commit a populated secret, a `.env` file, or a service-account JSON.\n\n== Lambda environment variables\n\nGlobal defaults from `Globals.Function.Environment` in `lambdas/template.yaml`:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Variable | Default | Purpose\n\n| `LOG_LEVEL` | `INFO` (stack parameter `LogLevel`) | Root logger level, applied by each function's `utils/logging.py`.\n| `SENTRY_DSN` | `\"\"` (empty) | Empty disables Sentry entirely. See xref:infrastructure:observability.adoc[Observability].\n| `SENTRY_ENVIRONMENT` | `prod` | Environment tag on Sentry events.\n|===\n\nPer function:\n\n.enrichment-scraper\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `SQS_QUEUE_URL` | `EnrichmentPipelineQueue` URL\n| `AGENTCORE_SECRET_ARN` | ARN of `{shared-secret}`\n| `SMARTSCOUT_BASE_URL` | `https://smartscoutapi-east.azurewebsites.net`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n|===\n\nAdditional `Settings` fields with code defaults (override via environment if\nneeded): `SMARTSCOUT_MARKETPLACE` (`US`), `ICP_REVENUE_MIN` (`100000`),\n`ICP_REVENUE_MAX` (`1000000`), `ICP_CATEGORIES` (empty, comma-separated),\n`MAX_PAGES_PER_RUN` (`1`), `PAGE_SIZE` (`10`),\n`BIGQUERY_CONTACT_SYNC_BATCH_SIZE` (`1000`).\n\n[NOTE]\n====\n`ICP_REVENUE_MIN`/`MAX` are *annual*. The provider filter divides both by 12 to\nget the monthly bounds SmartScout expects. The validator rejects\n`min >= max` at import time, so a bad value fails the cold start loudly.\n====\n\n.enrichment-scorer\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AGENTCORE_SECRET_ARN` | ARN of `{shared-secret}`\n| `SUBSCRIPTION_ID` | `{subscription-id}` — must match the scraper\n| `CRM_SYNC_QUEUE_URL` | `CrmSyncQueue` URL. Empty disables CRM publishing (DB writes still happen).\n|===\n\n.morpheus-hubspot\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AGENTCORE_SECRET_ARN` | ARN of `{shared-secret}`\n| `SUBSCRIPTION_ID` | `{subscription-id}` — events whose `subscription_id` differs are rejected\n| `CONTACT_HUBSPOT_RESOLVE_BATCH_SIZE` | `25` in the template (code default `100`) — rows per `Contact.ResolveHubSpotIds` run when the event omits `batch_limit`\n|===\n\nAdditional `Settings` fields with code defaults, overridable via environment:\n`HUBSPOT_TASK_POLL_BATCH_SIZE` (`100`),\n`BIGQUERY_ENGAGEMENT_SYNC_BATCH_SIZE` (`1000`),\n`FIVE9_CALL_SYNC_BATCH_SIZE` (`500`).\n\n.email-agent\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AGENTCORE_SECRET_ARN` | ARN of `{shared-secret}`\n|===\n\n[NOTE]\n====\n`email-agent` deliberately has *no* `SUBSCRIPTION_ID`. It is multi-tenant safe by\nderiving tenant scope from the loaded `EmailCampaignTarget` row. See\nxref:lambdas:email-agent.adoc#tenancy[tenant safety].\n====\n\n.phone-agent\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AGENTCORE_SECRET_ARN` | ARN of `{shared-secret}`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n| `CRM_SYNC_QUEUE_URL` | `CrmSyncQueue` URL\n| `EMAIL_AGENT_QUEUE_URL` | `EmailAgentQueue` URL — used by stage-1 task escalation\n| `PHONE_AGENT_BATCH_SIZE` | `25` — `target_phone_numbers` rows per Twilio run\n| `SALES_SLACK_CHANNEL_ID` | `sales-tasks`\n|===\n\n== AgentCore runtime environment variables\n\nInjected by the CDK stack into `CfnRuntime.environmentVariables`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AWS_REGION` | Stack region\n| `LOG_GROUP_NAME` | `/aws/bedrock-agentcore/agents/agentcore`\n| `MEMORY_ID` | Memory resource id from `CfnMemory`\n| `SECRET_ARN` | Secrets Manager ARN, only when `secretArn` is set on the stack props\n|===\n\nOptional runtime defaults read by `agentcore/config/loader.py`, all with code\ndefaults: `DEFAULT_AGENT_PROVIDER` (`claude`), `DEFAULT_MODEL_ID`,\n`DEFAULT_AGENT_NAME` (`AgentCore`), `DEFAULT_SYSTEM_PROMPT`,\n`DEFAULT_MAX_TOKENS` (`4096`), `DEFAULT_MAX_TURNS` (`10`), `MAX_TOKENS` (used by\n`constants.AGENT_MAX_TOKEN`).\n\n`SECRET_ARN` is mandatory — `load_config()` raises `RuntimeError` at import if it\nis absent. See xref:agentcore:configuration.adoc[runtime configuration].\n\n== Configuration held in the database\n\nSome state is neither a secret nor an environment variable — it is a row in the\n`configs` table, so it survives cold starts and can be changed without a deploy.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `configs.key` | Meaning\n\n| `enrichment.amazon.disabled`\n| `\"true\"` (case-insensitive) suspends Amazon discovery. Any other value, or absent, means active.\n\n| `enrichment.amazon.next_start_row`\n| Row offset where the next scheduled discovery run begins. `0` restarts from the top.\n\n| `bigquery_engagement_sync_watermark`\n| Last `lastupdated` timestamp consumed from the BigQuery engagements export.\n\n| `bq_contact_sync_watermark`\n| Last `updatedat` timestamp consumed from the BigQuery contacts export.\n\n| `five9_call_sync_watermark`\n| Last processed Files.com file modification time for Five9 CSVs.\n\n| `dialer_follow_up_rep_rr:<subscriptionId>`\n| Round-robin cursor for sales-rep assignment.\n|===\n\nFull detail: xref:data:configs-table.adoc[Runtime state in `configs`].\n"},{"component":"morpheus","module":"ROOT","path":"development.adoc","title":"Local development","description":"Toolchain, setup, running each unit locally, and the test suites.","url":"https://docx.payability.com/morpheus/development.html","navOrder":5,"source":"= Local development\n:description: Toolchain, setup, running each unit locally, and the test suites.\n\n== Toolchain\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Tool | Version | Used for\n\n| Python | {python-version}+ | Runtime and all Lambdas\n| `uv` | latest | Every Python dependency operation\n| `pnpm` | latest | CDK dependencies\n| Node.js | ≥ 20 | CDK CLI only\n| Docker | latest | AgentCore image builds\n| AWS CLI | v2 | Deploys, log tailing, queue inspection\n| AWS SAM CLI | latest | Lambda build, local invoke, deploy\n| AWS CDK CLI | v2 | Infrastructure deploy\n|===\n\n[WARNING]\n====\nPackage-manager rules are hard constraints in this repository:\n\n* `uv` for `agentcore/` and `libs/` — never `pip install` or `poetry`\n* `pnpm` for `cdk/` — never `npm install`\n* Lambda layer dependency lists are produced with\n  `uv pip compile pyproject.toml -o requirements.txt`\n====\n\nInstall `uv`:\n\n[source,bash]\n----\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n----\n\n== Repository setup\n\n[source,bash]\n----\n# Runtime dependencies\nmake install          # uv sync --directory agentcore\n\n# CDK dependencies\ncd cdk && pnpm install\n----\n\nEach Lambda and each library has its own `pyproject.toml` and its own virtual\nenvironment; run `uv sync` inside whichever directory you are working in.\n\n== Running the AgentCore runtime locally\n\n[source,bash]\n----\nexport SECRET_ARN=arn:aws:secretsmanager:us-east-1:625663093496:secret:agentcore-secrets-XXXXXX\nmake dev              # uv run --directory agentcore python -m agentcore.main\n----\n\n[CAUTION]\n====\nAlways `python -m agentcore.main`, never `python agentcore/main.py`. Running the\nfile directly puts `agentcore/` on `sys.path[0]`, so `agentcore/types/` shadows\nthe standard-library `types` module and the import graph breaks.\n====\n\nSend a test invocation:\n\n[source,bash]\n----\ncurl -X POST http://localhost:9000/invocations \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"workflow\":\"test\",\"payload\":{\"prompt\":\"What is 2 + 2?\"}}'\n----\n\nValid AWS credentials are required — the runtime reads the secret from Secrets\nManager and calls Bedrock through the ambient IAM identity. See\nxref:agentcore:development.adoc[AgentCore development].\n\n== Running Lambdas locally\n\nBuild once, then invoke a function with a sample event:\n\n[source,bash]\n----\ncd lambdas\nsam build\nsam local invoke MorpheusHubspotFunction \\\n  --event morpheus_hubspot/events/samples/contact_update.json\n----\n\nSample payloads live in `<function>/events/samples/`, each with a `README.md`\ndescribing when to use them. For a scheduled handler, hand-craft the EventBridge\nenvelope:\n\n[source,json]\n----\n{\"detail-type\": \"Hubspot.EmailClickReEngagement\", \"source\": \"morpheus.hubspot\", \"detail\": {}}\n----\n\n`sam local invoke` still reaches real AWS for Secrets Manager, SQS, Bedrock, and\nthe database, so treat every local run as production traffic against those\nsystems.\n\n== Tests\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Command | Scope\n\n| `make test`\n| AgentCore runtime tests (`uv run pytest agentcore/test/ -v`)\n\n| `cd lambdas && uv run pytest tests/ -v`\n| Cross-function unit tests\n\n| `cd lambdas && uv run pytest morpheus_hubspot/tests -v`\n| morpheus-hubspot handler tests (re-engagement, dialer filters, Five9, lifecycle)\n\n| `cd lambdas/enrichment_scraper && uv run pytest tests -v`\n| SmartScout provider and auth tests\n\n| `cd libs/morpheus-common && uv run pytest tests -v`\n| Event model validation tests\n\n| `cd libs/morpheus-data-layer && uv run pytest tests -v`\n| Repository tests; `tests/integration/` needs a reachable database\n\n| `cd libs/pyb-hubspot && uv run pytest tests -v`\n| HubSpot helper tests (mocked SDK)\n\n| `cd cdk && pnpm test`\n| CDK snapshot tests\n|===\n\nHandler modules defer imports of Lambda-layer packages inside `@lru_cache`\nfunctions (`_get_agent()`, `load_secrets()`), so a test can import the module and\npatch at the function boundary without the layer installed. Preserve that\npattern when adding agents.\n\nMore detail: xref:lambdas:testing.adoc[Testing Lambdas].\n\n== Documentation\n\nThis component is AsciiDoc for Antora. To preview, add this repository as a\ncontent source in the central playbook and build there — there is no standalone\nplaybook in this repository. See xref:docs-contributing.adoc[Writing these docs].\n\n== Conventions\n\nBefore changing code, read xref:conventions.adoc[Engineering conventions]; it\nmirrors `AGENTS.md`, the authoritative rule set for this repository.\n"},{"component":"morpheus","module":"ROOT","path":"deployment.adoc","title":"Deployment","description":"How each Morpheus unit is deployed, in what order, and what to verify afterwards.","url":"https://docx.payability.com/morpheus/deployment.html","navOrder":6,"source":"= Deployment\n:description: How each Morpheus unit is deployed, in what order, and what to verify afterwards.\n\nTwo independent deploy paths: CDK for the AgentCore runtime, SAM for the Lambda\napplication. Neither depends on the other at deploy time.\n\n== Order for a fresh environment\n\n. Deploy the SAM stack. It creates the shared secret (`{shared-secret}`) as an\n  empty resource, the queues, and the functions.\n. Populate the secret — see xref:configuration.adoc#secret[the shared secret].\n  Functions will fail their cold start until it is populated.\n. Apply the BigQuery views from `bigquery/*.sql`. Handlers that query a missing\n  view log the error and return.\n. Deploy the CDK stack for the AgentCore runtime.\n. Force a cold start on the Lambdas so they pick up the populated secret\n  (redeploy, or touch the function configuration).\n\n== SAM — Lambda application\n\nRun from `lambdas/`.\n\n[source,bash]\n----\nmake deploy               # sync all requirements → sam build --no-cached → sam deploy\n----\n\nPer-function deploys build only that resource, then deploy the stack;\nCloudFormation applies only what changed:\n\n[source,bash]\n----\nmake deploy-scraper\nmake deploy-scorer\nmake deploy-hubspot\nmake deploy-email-agent\nmake deploy-phone-agent\n----\n\nEach target first regenerates `requirements.txt` from `uv.lock`:\n\n[source,bash]\n----\ncd <function> && uv export --no-dev --no-hashes -o requirements.txt\n----\n\n[IMPORTANT]\n====\nA dependency added with `uv add` is not deployed until `requirements.txt` is\nre-exported. The `make deploy-*` targets do this for you; a bare `sam build`\ndoes not.\n====\n\nStack settings come from `lambdas/samconfig.toml`: stack name `{sam-stack}`,\n`CAPABILITY_IAM`, `resolve_s3 = true`, `confirm_changeset = false`. Resource\nnames are derived from the stack name, so the CRM queue is\n`{sam-stack}-crm-sync.fifo` and the CRM function is `{sam-stack}-hubspot-sync`.\n\nUseful outputs:\n\n[source,bash,subs=attributes+]\n----\naws cloudformation describe-stacks \\\n  --stack-name {sam-stack} \\\n  --query \"Stacks[0].Outputs\" --output table\n----\n\n`AgentcoreSecretArn`, `CrmSyncQueueUrl`, `EnrichmentPipelineQueueUrl`,\n`CrmSyncDlqUrl`, `EnrichmentPipelineDlqUrl`, and one ARN + IAM role per function.\n\n== CDK — AgentCore runtime\n\nRun from the repository root.\n\n[source,bash]\n----\nmake deploy-cdk           # cdk bootstrap (idempotent) + cdk deploy --all\nmake deploy-dev           # cdk deploy --all\nmake deploy-prod          # cdk deploy --all --require-approval never\n----\n\nOr from `cdk/`:\n\n[source,bash]\n----\npnpm install\nnpx cdk diff              # review before applying\nnpx cdk deploy\n----\n\nRequires a running Docker daemon: the stack builds the `agentcore/` image for\n`linux/arm64` via `DockerImageAsset` and pushes it to ECR.\n\nThe secret ARN is currently hardcoded in `cdk/bin/cdk.ts`. Update it when the\nsecret is recreated.\n\nOutputs: `AgentRuntimeId`, `AgentRuntimeArn`, `RuntimeRoleArn`, `ImageUri`,\n`LogGroupName`, and `SecretArn`. Copy `RuntimeRoleArn` into\n`agentcore/.bedrock_agentcore.yaml` as `execution_role` so the AgentCore CLI uses\nthe CDK-managed role rather than creating its own.\n\nDetails: xref:infrastructure:cdk-stack.adoc[CDK stack].\n\n== BigQuery views\n\nThere is no migration runner. Apply a changed view by executing its file:\n\n[source,bash]\n----\nbq query --use_legacy_sql=false < bigquery/v_lost_deal_reeng.sql\n----\n\nViews are `CREATE OR REPLACE`, so re-applying is safe. The handler queries are\npinned to view names in `lambdas/morpheus_hubspot/utils/constants.py` and\n`lambdas/phone_agent/utils/constants.py` — renaming a view requires a code\nchange and a Lambda deploy.\n\nSee xref:data:bigquery-views.adoc[BigQuery views].\n\n== Verifying a deploy\n\n[source,bash,subs=attributes+]\n----\n# Tail a function\naws logs tail /aws/lambda/{sam-stack}-hubspot-sync --follow\n\n# Tail the runtime\naws logs tail /aws/bedrock-agentcore/agents/agentcore --follow\n\n# Confirm nothing landed in a DLQ\naws sqs get-queue-attributes \\\n  --queue-url <CrmSyncDlqUrl> \\\n  --attribute-names ApproximateNumberOfMessages\n----\n\nTrigger a scheduled handler on demand rather than waiting for its rule:\n\n[source,bash,subs=attributes+]\n----\naws lambda invoke \\\n  --function-name {sam-stack}-hubspot-sync \\\n  --payload '{\"detail-type\":\"Hubspot.ContactResolve\",\"source\":\"morpheus.hubspot\",\"detail\":{}}' \\\n  --cli-binary-format raw-in-base64-out /dev/stdout\n----\n\n== Enabling a disabled trigger\n\nSeveral schedules and the `EnrichmentPipelineQueue` SQS trigger are commented out\nin `lambdas/template.yaml` — see xref:infrastructure:schedules.adoc[EventBridge\nschedules] for the current state of each. To enable one, uncomment the `Events`\nblock and redeploy. Two constraints:\n\n* An SQS-triggered function's `Timeout` must stay below the queue's\n  `VisibilityTimeout`.\n* The `Input` JSON `detail-type` must exactly match a key registered in that\n  function's `registry.py`, or the handler logs \"no handler registered\" and\n  returns.\n\n== CI\n\n`.github/workflows/security-review.yml` runs an automated security review of\npull-request diffs using the prompt in `.github/agents/security-risk-analysis.md`.\nThere is no deployment pipeline — deploys are run from a developer machine.\n"},{"component":"morpheus","module":"ROOT","path":"operations.adoc","title":"Operations and runbooks","description":"Day-to-day operation of Morpheus — logs, queues, watermarks, kill switches, common failures.","url":"https://docx.payability.com/morpheus/operations.html","navOrder":7,"source":"= Operations and runbooks\n:description: Day-to-day operation of Morpheus — logs, queues, watermarks, kill switches, common failures.\n\n== Where to look first\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Symptom | First place to look\n\n| A pipeline stage produced nothing\n| The function's CloudWatch log group; every handler logs a summary line on completion (`… completed updated=N skipped=N total=N`).\n\n| Messages disappearing\n| The relevant dead-letter queue. `maxReceiveCount` is 1 everywhere, so one handler failure sends the message straight to the DLQ.\n\n| A BigQuery-backed handler is a no-op\n| `BIGQUERY_CREDENTIALS` missing or empty in the secret — every such handler logs `BigQuery credentials not configured — skipping …` and returns.\n\n| Contacts not appearing on call lists\n| xref:business:call-lists.adoc[Daily call lists] — the suppression rules. Then confirm `Hubspot.SalesRepCallListRefresh` ran.\n\n| Enrichment is silently skipped\n| The seller already has `enrichedAt` set, or the AI step failed. Enrichment is best-effort: the failure is logged at `ERROR` and the message is still acked.\n\n| CRM changes not landing in HubSpot\n| `{sam-stack}-hubspot-sync` logs, then `CrmSyncDlq`. Subscription mismatch and empty-properties events are dropped by design and logged at `ERROR`.\n|===\n\n== Log groups\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Component | Log group\n\n| enrichment-scraper | `/aws/lambda/{sam-stack}-enrichment-scraper`\n| enrichment-scorer | `/aws/lambda/{sam-stack}-enrichment-scorer`\n| morpheus-hubspot | `/aws/lambda/{sam-stack}-hubspot-sync`\n| email-agent | `/aws/lambda/{sam-stack}-email-agent`\n| phone-agent | `/aws/lambda/{sam-stack}-phone-agent`\n| AgentCore runtime | `/aws/bedrock-agentcore/agents/agentcore`\n|===\n\nEvery Lambda calls `configure_logging(level, aws_request_id, event_type)` at the\nstart of an invocation, so each line carries the request id and the routing key.\nLog level is the `LogLevel` stack parameter (default `INFO`); `DEBUG` adds\nper-record detail.\n\n[source,bash,subs=attributes+]\n----\naws logs tail /aws/lambda/{sam-stack}-hubspot-sync --follow --since 30m\naws logs tail /aws/lambda/{sam-stack}-hubspot-sync \\\n  --filter-pattern '\"Hubspot.LostDealReEngagement\"' --since 1d\n----\n\n== Queues\n\n[cols=\"2,2,1\",options=\"header\"]\n|===\n| Queue | Physical name | FIFO\n\n| Enrichment pipeline | `{sam-stack}-enrichment-pipeline.fifo` | yes\n| Enrichment pipeline DLQ | `{sam-stack}-enrichment-pipeline-dlq.fifo` | yes\n| CRM sync | `{sam-stack}-crm-sync.fifo` | yes\n| CRM sync DLQ | `{sam-stack}-crm-sync-dlq.fifo` | yes\n| Email agent | `{sam-stack}-email-agent` | no\n| Email agent DLQ | `{sam-stack}-email-agent-dlq` | no\n|===\n\nDepth check:\n\n[source,bash]\n----\naws sqs get-queue-attributes --queue-url <url> \\\n  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible\n----\n\n=== Inspecting a DLQ message\n\n[source,bash]\n----\naws sqs receive-message --queue-url <dlq-url> \\\n  --max-number-of-messages 1 --visibility-timeout 0 \\\n  --message-attribute-names All --query 'Messages[0].Body' --output text\n----\n\nVisibility timeout 0 leaves the message in place. Read the `event_type` in the\nbody (or the `event_type` message attribute for the enrichment and email queues)\nand cross-reference xref:events:catalog.adoc[the event catalog].\n\n=== Replaying a DLQ\n\nDLQ retention is 14 days. Use an SQS redrive:\n\n[source,bash]\n----\naws sqs start-message-move-task \\\n  --source-arn <dlq-arn> \\\n  --destination-arn <source-queue-arn> \\\n  --max-number-of-messages-per-second 5\n----\n\n[WARNING]\n====\nFix the cause before replaying. With `maxReceiveCount: 1` a replayed message that\nstill fails lands back in the DLQ immediately, and FIFO ordering within a message\ngroup is not preserved by a redrive.\n====\n\n== Kill switches\n\nAmazon seller discovery::\nSet `configs.key = 'enrichment.amazon.disabled'` to `'true'`. The handler checks\nthis first and returns before touching SmartScout. Any other value re-enables it.\n\nAny scheduled handler::\nDisable its EventBridge rule:\n+\n[source,bash,subs=attributes+]\n----\naws events disable-rule --name {sam-stack}-lost-deal-reengagement\n----\n\nA whole SQS consumer::\nDisable the event-source mapping:\n+\n[source,bash]\n----\naws lambda list-event-source-mappings --function-name <fn> --query 'EventSourceMappings[].UUID'\naws lambda update-event-source-mapping --uuid <uuid> --no-enabled\n----\nMessages accumulate on the queue for up to four days (`MessageRetentionPeriod`\n345600 s) rather than being lost.\n\nSlack notifications::\nClear `SLACK_BOT_TOKEN` in the secret. `phone-agent` logs a warning and skips\nboth reminder phases.\n\nAll BigQuery segmentation::\nClear `BIGQUERY_CREDENTIALS`. Every BigQuery-backed handler becomes a no-op.\n\n== Watermarks and cursors\n\nIncremental syncs keep their position in the `configs` table rather than in\nmemory. To re-run a window, move the watermark backwards.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| `configs.key` | Owner | Effect of resetting\n\n| `bigquery_engagement_sync_watermark`\n| `Hubspot.BigQueryEngagementSync`\n| Re-reads HubSpot engagements from that timestamp. Writes are idempotent upserts on `(subscriptionId, externalId)`, so replay is safe.\n\n| `bq_contact_sync_watermark`\n| `Scraper.BigQueryContactSync`\n| Re-reads BigQuery contacts. Contact upserts are find-or-create; lifecycle-change Activity notes may be written again.\n\n| `five9_call_sync_watermark`\n| `Hubspot.Five9CallSync`\n| Re-processes Files.com CSVs modified after that time. `Call` upserts key on `providerCallId`, but HubSpot call engagements would be created again — reset with care.\n\n| `enrichment.amazon.next_start_row`\n| `SellerDiscovery.Amazon`\n| Sets the SmartScout row offset for the next run. `0` restarts discovery from the top.\n\n| `dialer_follow_up_rep_rr:<subscriptionId>`\n| Round-robin assignment\n| Resets which rep is next. Harmless.\n|===\n\n[source,sql]\n----\n-- inspect\nSELECT key, value, \"updatedAt\" FROM configs WHERE key LIKE '%watermark%';\n\n-- rewind one sync\nUPDATE configs SET value = '2026-08-01T00:00:00+00:00'\nWHERE key = 'bigquery_engagement_sync_watermark';\n----\n\nThe contact sync has a guard against stalling: if a full batch returns without\nadvancing the maximum `updatedat`, it bumps the watermark by one second and logs\na warning, so a cluster of identical timestamps cannot loop forever.\n\n== Alarms\n\n`BedrockTokenAlarm` (`{sam-stack}-bedrock-daily-tokens-high`) watches\n`AWS/Bedrock` `InputTokenCount`, summed hourly, and fires above 5,000,000 tokens\nper hour to the SNS topic `security-operational-issues`. Missing data is treated\nas not breaching. Tune the threshold in `lambdas/template.yaml`.\n\nApplication Insights is enabled over the SAM stack's resource group.\n\nSentry is wired but disabled: `SENTRY_DSN` is empty in `Globals`. Populating it\nturns on unhandled-exception capture plus `ERROR`-level log forwarding, with\n`send_default_pii=False`. See\nxref:infrastructure:observability.adoc[Observability].\n\n== Recurring operational notes\n\nDuplicated work is cheap, missed work is not::\nEvery handler is written to be safely re-runnable: upserts key on natural unique\ncolumns, \"already called today\" suppression is recomputed each run, and\nenrichment/scoring skip rows that already have `enrichedAt` / `payoutPainScore`.\n\nSchedules currently run at test cadence::\nSeveral rules use `rate(...)` values that were set for development (for example\nthe re-engagement segments at `rate(6 hours)` and the Five9 sync's commented-out\n`rate(4 minutes)`). Confirm the intended production cadence in\nxref:infrastructure:schedules.adoc[EventBridge schedules] before assuming a\nhandler runs on the cadence its description claims.\n\nThe scraper writes to Secrets Manager::\n`enrichment-scraper` caches its SmartScout session token back into the shared\nsecret. That is why it holds `secretsmanager:PutSecretValue` and why a manual\n`put-secret-value` that omits `SMARTSCOUT_TOKEN` simply forces one extra\nauthentication.\n\nNever log payloads::\nThe convention is to log identifiers and counts, not bodies, because messages\nroutinely carry contact PII. One known deviation: the `morpheus-hubspot`\ndispatcher includes the full message body in its \"Handler failed\" `ERROR` line,\nso that log group can contain PII after a failure. Preserve the convention when\nadding diagnostics. See xref:security.adoc[Security and data handling].\n"},{"component":"morpheus","module":"ROOT","path":"security.adoc","title":"Security and data handling","description":"Credential handling, PII rules, tenant isolation, IAM posture, and the prompt-injection surface.","url":"https://docx.payability.com/morpheus/security.html","navOrder":8,"source":"= Security and data handling\n:description: Credential handling, PII rules, tenant isolation, IAM posture, and the prompt-injection surface.\n\n== Credentials\n\n* Every credential lives in one Secrets Manager secret, `{shared-secret}`. Nothing\n  is baked into an image, a template, or source code.\n* Each consumer declares only the keys it needs in a Pydantic model with\n  `extra=\"ignore\"`, so an unrelated key cannot leak into an unrelated component.\n* Secret reads are cached per container (`@lru_cache` in Lambdas, a module global\n  in the runtime): one Secrets Manager call per container lifetime.\n* Libraries never read `os.environ`. `create_hubspot_mcp()`, `create_tavily_mcp()`,\n  and `create_bigquery_client()` all take credentials as explicit keyword\n  arguments; the caller fetches them. This keeps credential provenance visible at\n  the call site.\n* `.env` files and service-account JSON must never be committed. `.gitignore`\n  covers the common cases; `enrichment_scraper/.env.example` documents the shape\n  without values.\n\nTwo HubSpot auth modes are supported, in priority order: a Private App token\n(`HUBSPOT_PRIVATE_APP_TOKEN`, preferred — static, no refresh) and the OAuth\ntriple (fallback). `morpheus-hubspot` validates at load time that at least one\ncomplete mode is present and fails the cold start otherwise.\n\n== PII\n\nThe platform handles names, email addresses, phone numbers, and business\nidentifiers for real people.\n\nLogging rules::\nLog identifiers (`sellerId`, `hubspot_contact_id`, row UUIDs) and counts. Do not\nlog message bodies, transcripts, or email content. Use `logger.debug()` for\nper-record detail so it is off in production. Always pass `exc_info=True` on a\ncaught exception rather than reformatting the payload into the message.\n+\nKnown deviation: the `morpheus-hubspot` dispatcher logs the full message body in\nits \"Handler failed\" `ERROR` line.\n\nSentry::\nInitialised with `send_default_pii=False`, which strips IP addresses and user\nagents. `LoggingIntegration` forwards `ERROR`-level records as events, so any PII\nplaced in an error message reaches Sentry. Currently disabled — `SENTRY_DSN` is\nempty.\n\nEmail content::\nInbound reply text is passed to Bedrock for classification and is not persisted\nby `email-agent` beyond the category, timestamp, and model label. The `emails`\ntable (written by the web application) does hold bodies.\n\nSuppression::\nOpt-outs are authoritative. `Contact.Unsubscribe` writes HubSpot Communication\nPreferences; the `contact.propertyChange` webhook processor mirrors\n`hs_email_optout_<id>` into `target_emails.newsletterOptOut`,\n`oneToOneOptOut`, and `marketingOptOut`. Re-engagement segments exclude blocked\ndispositions and do-not-contact reasons in SQL. See\nxref:business:email-engagement.adoc[Email engagement].\n\n== Tenant isolation\n\nEvery domain table is scoped by `subscriptionId`. Isolation is enforced in three\ndifferent ways depending on the component:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Component | Mechanism\n\n| enrichment-scraper, enrichment-scorer, phone-agent, morpheus-hubspot\n| `SUBSCRIPTION_ID` environment variable. `morpheus-hubspot` additionally rejects any event whose `subscription_id` does not match, logging at `ERROR` and returning without raising.\n\n| email-agent\n| No `SUBSCRIPTION_ID` at all. Tenant scope is derived from the loaded `EmailCampaignTarget.subscriptionId`, then cross-checked against the `Contact`, the `EmailVariation`, and the campaign id. Any drift is logged at `ERROR` and the handler returns. See xref:lambdas:email-agent.adoc#tenancy[tenant safety].\n|===\n\nA mismatch is treated as bad data, never retried — a poison message from a\nmisconfigured producer must not loop forever.\n\n== IAM posture\n\nThe Lambda functions use SAM-generated roles with narrow policies:\n\n* `SQSPollerPolicy` / `SQSSendMessagePolicy` scoped to the specific queue name.\n* `secretsmanager:GetSecretValue` scoped to `AgentcoreSecret` only.\n  `enrichment-scraper` additionally holds `PutSecretValue` on the same secret\n  because it caches the SmartScout session token there.\n* `bedrock:InvokeModel` and `InvokeModelWithResponseStream` on `*` for the two\n  functions that call Bedrock (`enrichment-scorer`, `email-agent`). Bedrock\n  model ARNs are not enumerable in advance for cross-region inference profiles.\n\nThe AgentCore runtime role is defined in the CDK stack with per-statement SIDs —\nECR pull, CloudWatch Logs, X-Ray, namespaced CloudWatch metrics, AgentCore\nworkload identity, Bedrock invocation, AWS Marketplace subscription (required for\nfirst-use model subscription), Secrets Manager read scoped to the secret ARN, and\nAgentCore Memory operations scoped to the memory ARN. Full statement list:\nxref:infrastructure:iam.adoc[IAM].\n\nCDK owns all IAM. Importing an externally managed role with\n`iam.Role.fromRoleArn()` is prohibited — see\nxref:conventions.adoc[Engineering conventions].\n\n== Prompt injection\n\nTwo agents consume untrusted text.\n\nInbound email categorisation (`email-agent`)::\n`inbound_text` is verbatim sender content, interpolated into the prompt without\nsanitisation. This is accepted, with two controls:\n+\n* Structured output — `EmailReplyCategorisationResult.category` is constrained to\n  the `EmailTargetCategory` enum, so the model cannot emit arbitrary JSON or\n  invoke tools.\n* The agent is constructed with `tools=lambda: []` — no tool surface at all.\n+\nResidual risk is bounded *misclassification* (a reply containing \"always choose\ninterested\"), not code execution or data exfiltration. This trade-off is\ndocumented in `lambdas/email_agent/agents/prompts.py`.\n\nSeller enrichment (`enrichment-scorer`)::\nThe enrichment agent reads arbitrary web content through the Tavily MCP tool and\nhas HubSpot MCP write access in the same session. Output is constrained to\n`SellerEnrichmentResult`, `max_turns` is capped at 6, and every result is\nvalidated before it reaches the database. Content fetched from the open web is\nthe injection surface here.\n\n`strands_tools` vended tools — `shell`, `python_repl`, `editor`, `http_request` —\nare wired into the runtime's `dialler_agent`. `shell` and `python_repl` grant\nin-container code execution to the model. Only expose them to workflows whose\ninputs you control.\n\n== Automated security review\n\n`.github/workflows/security-review.yml` runs a security analysis over pull-request\ndiffs using the prompt in `.github/agents/security-risk-analysis.md`. Locally, the\n`/security-review` skill performs the equivalent review of the current branch.\n"},{"component":"morpheus","module":"ROOT","path":"conventions.adoc","title":"Engineering conventions","description":"The rules that govern changes in this repository, mirroring AGENTS.md.","url":"https://docx.payability.com/morpheus/conventions.html","navOrder":9,"source":"= Engineering conventions\n:description: The rules that govern changes in this repository, mirroring AGENTS.md.\n\n`AGENTS.md` at the repository root is the authoritative rule set for both humans\nand AI coding agents. This page restates it in documentation form; where the two\ndisagree, `AGENTS.md` wins.\n\n== Hard rules\n\n[WARNING]\n====\n[source]\n----\nNEVER use pip or poetry for agentcore/ or libs/ — always use uv\nNEVER import between agentcore/, lambdas/, and libs/ at runtime\nNEVER use src.* import paths — they do not exist in this repo\nNEVER split the CDK into more than one stack (AgentCoreStack only)\nNEVER use iam.Role.fromRoleArn() in CDK — CDK owns all IAM\nNEVER put agent config (model ID, prompt, provider) in CDK or SAM env vars\nNEVER commit .env files or any file containing real API keys or secrets\nNEVER log full invocation payloads — they may contain PII\nNEVER use npm install in cdk/ — use pnpm\nNEVER run python agentcore/main.py — always python -m agentcore.main\nNEVER create a new CDK stack — everything goes in AgentCoreStack\n----\n====\n\n== Import conventions\n\nEach unit has its own import root. Crossing them at runtime is prohibited.\n\n`agentcore/`::\nAbsolute `agentcore.*` imports. `PYTHONPATH=/app` is set in the container.\n+\n[source,python]\n----\nfrom agentcore.types import AgentResult, RunOptions   # correct\nfrom agentcore.constants import MEMORY_ID             # correct\nfrom src.types import AgentResult                     # WRONG — src does not exist\n----\n\n`lambdas/<function>/`::\nEach function directory is its own package root, so imports are top-level:\n+\n[source,python]\n----\nfrom models.seller import SellerRecord\nfrom events.base import BaseEventHandler\nfrom utils.constants import INT32_MAX\n----\n\n`libs/`::\nEach library uses its own package prefix:\n+\n[source,python]\n----\nfrom morpheus_llm.providers import ClaudeAgentService\nfrom morpheus_llm.tools import create_hubspot_mcp, create_tavily_mcp\nfrom morpheus_data_layer.repositories import SellerRepository, ContactRepository\nfrom morpheus_data_layer.session import get_session\nfrom morpheus_common.sentry import configure as configure_sentry\nfrom morpheus_common.events import publish_event\nfrom pyb_hubspot import upsert_company, upsert_contact\n----\n\n== Package managers\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Context | Tool | Command\n\n| `agentcore/` dependencies | `uv` | `uv sync`, `uv add <pkg>`\n| `libs/` dependencies | `uv` | `uv add <pkg>` in the library directory\n| Lambda deploy manifests | `uv export` | `uv export --no-dev --no-hashes -o requirements.txt`\n| `cdk/` dependencies | `pnpm` | `pnpm install`, `pnpm add <pkg>`\n|===\n\n== Logging\n\n* `logger = logging.getLogger(__name__)`. Never `print()`.\n* Structured `key=value` fields inside the message string:\n  `\"sellerId=%r name=%r\"`.\n* `exc_info=True` on every caught exception.\n* `logger.debug()` for per-record detail that should be off in production.\n* Never log payloads or secrets.\n* End multi-row handlers with a summary line — `\"… completed updated=%d skipped=%d total=%d\"` —\n  so a run can be assessed from one line.\n\n== Pydantic\n\nPydantic v2 throughout.\n\n* `model_config = ConfigDict(extra=\"ignore\")` for EventBridge envelopes that use\n  only some fields.\n* `model_config = ConfigDict(str_strip_whitespace=True, frozen=True)` for\n  immutable domain models such as `SellerRecord`.\n* `field_validator` / `model_validator` (v2), never the v1 `@validator`.\n* `model_dump_json()` for serialisation, never `json.dumps(model.dict())`.\n* Cross-field requirements belong in a `model_validator(mode=\"after\")` so an\n  invalid message is rejected at the boundary. Example: `ContactUnsubscribeEvent`\n  requires at least one of `hubspot_contact_id` or `contact_email`.\n\n== Data layer\n\n* Attribute names are camelCase and match the database column exactly — no\n  `name=` override in `mapped_column`.\n* `ForeignKey(\"subscriptions.id\")` is not declared in the ORM; that table is not\n  managed here and the constraint is enforced at the database level only.\n* One model per file in `morpheus_data_layer/models/`, exported from\n  `models/__init__.py`.\n* Repositories are stateless and instantiated once at module level:\n  `_repo = SellerRepository()`.\n* Repositories never open sessions and never commit. The caller owns the\n  transaction: `with get_session() as session:`.\n* Every new repository is exported from `repositories/__init__.py`.\n* Use `upsert()` with `conflict_columns` for idempotent writes, naming columns in\n  camelCase.\n\nSee xref:data:index.adoc[Data].\n\n== Event handlers\n\nAdding an event type to a Lambda is always the same four steps:\n\n. Create `events/<name>.py` with a handler extending that function's base class\n  (`BaseEventHandler`, `BaseEnrichmentHandler`, `BaseEmailEventHandler`,\n  `BasePhoneEventHandler`).\n. Define or reuse a Pydantic model for the payload.\n. Register the routing key in `registry.py`.\n. Add the trigger to `lambdas/template.yaml` — an SQS `Events` entry, or a\n  `Schedule` with an `Input` whose `detail-type` matches the registry key\n  exactly.\n\nNever add routing logic to `handler.py`. Full walkthrough:\nxref:events:adding-an-event.adoc[Adding a new event].\n\n=== Best-effort rule\n\nIn the enrichment pipeline, the database write commits first; the AI enrichment\nand scoring steps run afterwards inside `try`/`except`. Those steps must never\ncause an SQS retry — catch everything, log at `ERROR`, return.\n\n== CDK\n\n* One stack: `AgentCoreStack` in `cdk/lib/stacks/agentcore-stack.ts`.\n* Never import an external role with `fromRoleArn()`.\n* No business logic — providers, models, and prompts live in runtime code.\n* Docker build context is `agentcore/`; platform target `linux/arm64`.\n* `pnpm` only.\n\n== SAM\n\n* All functions and layers live in the single `lambdas/template.yaml`.\n* Layers are built from `libs/` with `BuildMethod: python3.12`.\n* Every handler calls `configure_sentry()` at cold start, before `load_secrets()`.\n* Credentials come from `AgentcoreSecret` — never from environment variables.\n* SQS triggers declare `ReportBatchItemFailures`.\n* Scheduled triggers set the routing key with `Input: '{\"detail-type\":\"…\"}'`.\n* Layer order in every function:\n+\n[source,yaml]\n----\nLayers:\n  - !Ref MorpheusCommonLayer   # always first — Sentry\n  - !Ref MorpheusDataLayer     # if DB access is needed\n  - !Ref MorpheusLlmLayer      # if an AI agent is needed\n----\n\n== morpheus-llm\n\n* New tools go in `morpheus_llm/tools/builtin/<name>.py`, use the `@tool`\n  decorator from `strands` (docstring is the description, annotations are the\n  schema), and are exported from `tools/__init__.py`.\n* MCP tools are factory functions taking credentials as explicit keyword\n  arguments — never read from `os.environ` inside the library.\n* Model ids always come from `morpheus_llm.utils.AgentModelIds`. Do not strip the\n  `us.` prefix; it selects the cross-region inference profile.\n* For structured results, set `AgentConfig.structured_output_model` and read\n  `result.structured_output`. Never parse JSON out of `result.output`.\n\n== Common mistakes\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Mistake | Correct approach\n\n| `from src.types import …` | `from agentcore.types import …`\n| `pip install <pkg>` in `agentcore/` | `uv add <pkg>`\n| `npm install` in `cdk/` | `pnpm install`\n| `python agentcore/main.py` | `python -m agentcore.main`\n| Reading secrets inside a library tool factory | Accept credentials as keyword arguments; the caller fetches them\n| `json.loads(result.output)` for structured output | `result.structured_output`\n| Standalone helper functions for shared Lambda logic | A shared base class with inherited methods\n| Referencing `confirmed_email` or `hubspotContactId` on `Seller` | Those columns no longer exist — see `models/seller.py`\n| `ForeignKey(\"subscriptions.id\")` in an ORM model | Omit it; enforced at the database level\n| Hardcoding `SENTRY_DSN` in `template.yaml` | Reference SSM Parameter Store or Secrets Manager\n| Adding a dependency without re-exporting `requirements.txt` | `make deploy-<fn>` in `lambdas/`, which runs `uv export` first\n|===\n"},{"component":"morpheus","module":"ROOT","path":"docs-contributing.adoc","title":"Writing these docs","description":"How this Antora component is structured, consumed, and extended.","url":"https://docx.payability.com/morpheus/docs-contributing.html","navOrder":10,"source":"= Writing these docs\n:description: How this Antora component is structured, consumed, and extended.\n\n`docs/` is an Antora *documentation component*, not a standalone site. It has no\nplaybook of its own: the central documentation project consumes it as a content\nsource and builds it alongside other components.\n\n== Consuming this component\n\nAdd the repository to the central playbook's content sources:\n\n[source,yaml]\n----\ncontent:\n  sources:\n    - url: https://github.com/<org>/agentcore.git\n      branches: [main]\n      start_path: docs\n----\n\n`start_path: docs` is what makes Antora find `docs/antora.yml`.\n\n== Component descriptor\n\n`docs/antora.yml`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Key | Value\n\n| `name` | `morpheus` — the component name used in every cross-component xref\n| `title` | `Morpheus` — display name in the component selector\n| `version` | `~` — unversioned; pages resolve without a version segment in the URL\n| `start_page` | `ROOT:index.adoc`\n| `nav` | One entry per module nav file, in menu order\n| `asciidoc.attributes` | Shared facts (account, region, stack names, tenant id, BigQuery project) so a value is written once\n|===\n\nReferencing this component from another one:\n\n[source,asciidoc]\n----\nxref:morpheus:ROOT:index.adoc[Morpheus]\nxref:morpheus:business:re-engagement.adoc[Re-engagement segments]\n----\n\n== Modules\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Module | Scope\n\n| `ROOT` | Platform-wide: architecture, configuration, development, deployment, operations, security, conventions\n| `business` | Domain rules — what the platform decides and why\n| `agentcore` | The Bedrock AgentCore container runtime\n| `lambdas` | One page per Lambda function\n| `events` | Event catalog, payload schemas, routing\n| `libs` | Shared library reference\n| `data` | Domain model, ORM, repositories, BigQuery\n| `infrastructure` | CDK, SAM, queues, schedules, IAM, observability\n| `plans` | Forward-looking design documents\n|===\n\nStandard module layout:\n\n[source]\n----\nmodules/<name>/\n├── nav.adoc          navigation fragment listed in antora.yml\n├── pages/            .adoc pages — one URL each\n├── partials/         reusable fragments, included with include::partial$…[]\n├── examples/         verbatim files, included with include::example$…[]\n├── images/           referenced with image::…[]\n└── attachments/      downloadable files, linked with xref:attachment$…[]\n----\n\n== Authoring rules\n\nPages are AsciiDoc::\nAntora does not build Markdown. Every page is `.adoc` with a level-0 title\n(`= Title`) and section levels stepping down by one.\n\nEvery page carries a description::\n[source,asciidoc]\n----\n= Page title\n:description: One sentence, used for search results and metadata.\n----\n\nCross-references use resource ids, never file paths::\n[source,asciidoc]\n----\nxref:architecture.adoc[System architecture]            <1>\nxref:business:re-engagement.adoc[Re-engagement]        <2>\nxref:business:call-lists.adoc#suppression[Suppression] <3>\n----\n<1> Same module.\n<2> Another module in this component.\n<3> A section anchor in another module's page.\n\nAnchors are explicit when linked to::\n`idprefix`/`idseparator` are set, so headings get predictable ids, but any\nheading that is an xref target should declare its own anchor:\n+\n[source,asciidoc]\n----\n[#suppression]\n== Suppression rules\n----\n\nDiagrams are Mermaid in a `[mermaid]` literal block::\n[source,asciidoc]\n----\n[mermaid]\n....\nflowchart LR\n  A --> B\n....\n----\n+\nWith `asciidoctor-kroki` registered in the playbook these render as diagrams;\nwithout it they degrade to readable literal text. Register the extension in the\nplaybook to get rendered output:\n+\n[source,yaml]\n----\nasciidoc:\n  extensions:\n    - asciidoctor-kroki\n----\n\nShared facts come from attributes::\nWrite `{aws-region}`, `{sam-stack}`, `{subscription-id}`, `{bq-project}` rather\nthan literals. In a code block, opt in to substitution:\n+\n[source,asciidoc]\n-----\n[source,bash,subs=attributes+]\n----\naws logs tail /aws/lambda/{sam-stack}-hubspot-sync --follow\n----\n-----\n\nCode references point at real paths::\nName the file so a reader can open it — `lambdas/morpheus_hubspot/registry.py`,\n`libs/morpheus-llm/morpheus_llm/providers/claude_service.py`. Do not paste large\nverbatim blocks that will drift; quote the few lines that carry the rule.\n\nAdmonitions carry weight::\n`NOTE` for context, `TIP` for a shortcut, `IMPORTANT` for something easy to get\nwrong, `WARNING` for a rule with consequences, `CAUTION` for a destructive\naction. Use them sparingly enough that they still register.\n\n== Adding a page\n\n. Create `modules/<module>/pages/<name>.adoc` with a title and `:description:`.\n. Add an `xref:` line to that module's `nav.adoc` in the right place.\n. Cross-link it from any page whose reader would want it.\n\nAdding a whole module also requires appending its `nav.adoc` to the `nav:` list\nin `docs/antora.yml`, or nothing in it will appear in the menu.\n\n== Keeping docs true\n\nThe pages describing behaviour — every page under `business`, `events`, and\n`lambdas` — were written against the source. When you change a rule, change the\npage in the same pull request. The highest-risk drift points:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| If you change… | Also update\n\n| A `registry.py` entry | xref:events:catalog.adoc[Event catalog]\n| A Pydantic event model in `morpheus_common/events/` | The matching queue page under `events`\n| A schedule or `Input` in `lambdas/template.yaml` | xref:infrastructure:schedules.adoc[EventBridge schedules] and xref:events:scheduled.adoc[Scheduled events]\n| A priority formula in `bigquery/*.sql` | xref:business:re-engagement.adoc[Re-engagement segments] and xref:business:call-lists.adoc[Daily call lists]\n| A constant in a `utils/constants.py` | The relevant `business` page\n| A secret key or environment variable | xref:configuration.adoc[Configuration and secrets]\n| An ORM model or repository | xref:data:orm-models.adoc[ORM models] / xref:data:repositories.adoc[Repositories]\n|===\n"},{"component":"morpheus","module":"business","path":"business:index.adoc","title":"Business logic","description":"The domain rules Morpheus enforces — what it decides, on what evidence, and why.","url":"https://docx.payability.com/morpheus/business/index.html","navOrder":11,"source":"= Business logic\n:description: The domain rules Morpheus enforces — what it decides, on what evidence, and why.\n\nThis module is the answer to \"why did the system do that?\". Every page describes\nrules that are implemented in code, with the thresholds, formulas, and\nsuppression conditions spelled out and the implementing file named.\n\n== The proposition\n\nPayability advances marketplace sellers their revenue ahead of the platform's\npayout cycle. A seller who is growing, holds a lot of inventory, and waits two\nweeks for Amazon to pay feels that gap acutely. Morpheus exists to find those\nsellers, prove they are worth a call, and put them in front of a human at the\nmoment a call is most likely to land.\n\nThree judgements drive everything:\n\n. *Is this seller in the ideal customer profile?* — revenue band, category,\n  marketplace. Decided at discovery.\n. *How acutely do they feel payout pain?* — the payout-pain score, 0–100.\n  Decided by an AI pass over structured seller data and enrichment notes.\n. *Is now the right moment to call?* — re-engagement segmentation and priority.\n  Decided every few hours from CRM engagement data.\n\n== Rule pages\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Page | What it decides\n\n| xref:funnel.adoc[Outbound funnel]\n| The end-to-end lifecycle from marketplace row to booked demo, and which component owns each transition\n\n| xref:seller-discovery.adoc[Seller discovery]\n| Which sellers enter the pipeline: ICP bounds, pagination, the kill switch\n\n| xref:seller-enrichment.adoc[Seller enrichment]\n| How contact intelligence is gathered and written back, and what is skipped\n\n| xref:payout-pain-score.adoc[Payout-pain score]\n| The eight signals and weights behind `payoutPainScore`\n\n| xref:crm-sync.adoc[CRM sync]\n| How sellers become HubSpot Companies and contacts become HubSpot Contacts, and the search priority for matching\n\n| xref:re-engagement.adoc[Re-engagement segments]\n| The five segments that surface a contact for another call, with each priority formula\n\n| xref:call-lists.adoc[Daily call lists]\n| How today's list is assembled, prioritised, and materialised in HubSpot\n\n| xref:follow-up-tasks.adoc[Follow-up tasks and escalation]\n| Task creation, HubSpot mirroring, the three-stage Slack escalation ladder\n\n| xref:email-engagement.adoc[Email engagement]\n| Reply categorisation, engagement events, opt-outs and suppression\n\n| xref:phone-intelligence.adoc[Phone line intelligence]\n| Line-type classification, risk tiers, and automatic blocking\n\n| xref:call-logging.adoc[Call logging and dispositions]\n| AI dialer and Five9 call sync, disposition mapping, lifecycle promotion\n\n| xref:ownership.adoc[Ownership and round-robin]\n| Who a contact gets assigned to, and when ownership is overridden\n|===\n\n== Cross-cutting rules\n\nThese hold everywhere and are not repeated on each page.\n\nSingle tenant, explicitly checked::\nProduction runs one subscription, `{subscription-id}`. Handlers that receive a\n`subscription_id` on the event compare it to their configured value and drop\nmismatches. `email-agent` instead derives the tenant from the row it loaded. See\nxref:ROOT:security.adoc#tenant-isolation[tenant isolation].\n\nIdempotence over exactness::\nEvery write is designed to be safely repeatable — `ON CONFLICT DO UPDATE` upserts\non natural keys, find-or-create for contacts, \"already done\" guards on\n`enrichedAt` and `payoutPainScore`. A handler that runs twice must not double\nanything that matters.\n\nSuppression beats enrolment::\nWhen a suppression rule and an enrolment rule both apply, suppression wins.\nBlocked dispositions, do-not-contact reasons, open deals, and \"called today\"\nchecks all remove a contact from a segment before any priority is computed.\n\nUS Eastern is the business day::\n\"Today\" for call lists means the calendar date in US Eastern, approximated as\nUTC−4 in code. The daily list refresh runs at 6 AM ET, an hour at which EDT and\nEST agree on the date, so the fixed offset is safe there. Handlers that run\naround midnight UTC can disagree with a true `America/New_York` date by an hour\nduring EST.\n\nOwnership is sticky, except when it is not real::\nA contact keeps its owner. Reassignment happens only when there is no owner, the\nowner is the AI fallback owner (`86817215`), or the owner is not an active\n`sales_reps` row. See xref:ownership.adoc[Ownership and round-robin].\n\nAI output is validated, never trusted raw::\nEvery agent that produces data uses Strands structured output with a Pydantic\nmodel. Nothing parses JSON out of free-form model text.\n"},{"component":"morpheus","module":"business","path":"business:funnel.adoc","title":"Outbound funnel","description":"The end-to-end lifecycle from a marketplace data row to a booked demo, and which component owns each transition.","url":"https://docx.payability.com/morpheus/business/funnel.html","navOrder":12,"source":"= Outbound funnel\n:description: The end-to-end lifecycle from a marketplace data row to a booked demo, and which component owns each transition.\n\n== Stages\n\n[mermaid]\n....\nflowchart TD\n  A[\"Marketplace row\\nSmartScout search result\"] --> B[\"Seller row\\nsellers.lifeCycleStage = new\"]\n  B --> C[\"Enriched seller\\nwebsite, LinkedIn, contacts\\nlifeCycleStage = enriched\"]\n  C --> D[\"Scored seller\\npayoutPainScore 0-100\"]\n  D --> E[\"HubSpot Company\\n+ associated Contacts\"]\n  E --> F[\"Outreach\\nemail campaign / AI dialer\"]\n  F --> G[\"Engagement signal\\nclick, reply, call outcome, account created\"]\n  G --> H[\"Re-engagement segment\\nBigQuery view membership\"]\n  H --> I[\"Daily call list\\ncall_list_date + call_priority + owner\"]\n  I --> J[\"Rep call\\nFive9 or Nooks\"]\n  J --> K[\"Follow-up task\\nfollow_up_tasks + HubSpot Task\"]\n  K --> L[\"Deal\\nHubSpot Deal / demo booked\"]\n  J -->|no answer, stale| H\n  L -->|lost or stalled| H\n....\n\n== Who owns each transition\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Transition | Owner | Rule reference\n\n| Marketplace row → seller row\n| xref:lambdas:enrichment-scraper.adoc[`enrichment-scraper`] → `EnrichmentPipelineQueue` → xref:lambdas:enrichment-scorer.adoc[`enrichment-scorer`]\n| xref:seller-discovery.adoc[Seller discovery]\n\n| Seller row → enriched\n| `enrichment-scorer`, first AI pass (Claude Haiku 4.5 + Tavily + HubSpot MCP)\n| xref:seller-enrichment.adoc[Seller enrichment]\n\n| Enriched → scored\n| `enrichment-scorer`, second AI pass (Claude Haiku 3, no tools)\n| xref:payout-pain-score.adoc[Payout-pain score]\n\n| Scored → HubSpot Company + Contacts\n| `Seller.CrmSync` → xref:lambdas:morpheus-hubspot.adoc[`morpheus-hubspot`]\n| xref:crm-sync.adoc[CRM sync]\n\n| HubSpot → outreach\n| The Payability web application (campaigns, sequences, AI dialer) — outside this repository\n| —\n\n| Outreach → engagement signal\n| HubSpot engagements, `email_campaign_targets` state, `Call` rows; mirrored into BigQuery\n| xref:email-engagement.adoc[Email engagement], xref:call-logging.adoc[Call logging]\n\n| Engagement signal → segment membership\n| BigQuery views in `bigquery/*.sql`\n| xref:re-engagement.adoc[Re-engagement segments]\n\n| Segment → daily call list\n| Five scheduled handlers across `morpheus-hubspot` and `phone-agent`\n| xref:call-lists.adoc[Daily call lists]\n\n| Call list → materialised HubSpot list\n| `Hubspot.SalesRepCallListRefresh`, daily\n| xref:call-lists.adoc#materialisation[List materialisation]\n\n| Rep call → logged call\n| `Crm.DialerCallEnded` (AI dialer) and `Hubspot.Five9CallSync` (human dialer)\n| xref:call-logging.adoc[Call logging and dispositions]\n\n| Call → follow-up task\n| `Crm.TaskCreate`, plus `Phone.CampaignTargetReEngagement`\n| xref:follow-up-tasks.adoc[Follow-up tasks and escalation]\n\n| Task → completion or failure\n| `Hubspot.FollowUpTaskPoll` and `Phone.TaskDeadlineReminder`\n| xref:follow-up-tasks.adoc#escalation[Escalation ladder]\n\n| Deal stage changes\n| `Crm.DealUpdate`\n| xref:crm-sync.adoc#deals[Deals]\n|===\n\n== Seller lifecycle stages\n\n`sellers.lifeCycleStage` (`SellerLifeCycleStage` enum). The pipeline only ever\nwrites `new` (at ingest) and `enriched` (after a successful enrichment pass).\nThe later stages exist for the wider platform.\n\n[source]\n----\nnew → raw_lead → enriched → in_sequence → engaged → qualified\n    → bdr_handoff → demo_scheduled → opportunity → customer\n    (or disqualified)\n----\n\nTwo columns act as pipeline gates rather than stages:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Column | Gate\n\n| `enrichedAt`\n| Non-null means the enrichment pass has run. `SellerReceivedHandler` skips enrichment when it is set; `get_pending_enrichment()` selects rows where `lifeCycleStage = new` and it is null.\n\n| `payoutPainScore`\n| Non-null means the scoring pass has run. `get_pending_scoring()` selects enriched rows where it is null.\n|===\n\n== Contact lifecycle stages\n\n`contacts.lifecycleStage` mirrors HubSpot's `lifecyclestage`. Morpheus only ever\npromotes, never demotes: `maybe_promote_lifecycle_stage()` in\n`lambdas/morpheus_hubspot/services/lifecycle_stage.py` ranks the current stage and\nwrites `marketingqualifiedlead` only when the current rank is lower.\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Stage | Rank\n\n| `subscriber` | 0\n| `lead` | 1\n| `other` | 1\n| `marketingqualifiedlead` | 2\n| `salesqualifiedlead` | 3\n| `opportunity` | 4\n| `customer` | 5\n| `evangelist` | 6\n|===\n\nThe whole function is best-effort: a CRM read or write failure is logged and the\ncaller continues. See xref:call-logging.adoc#lifecycle[lifecycle promotion].\n\n== Two directions of travel\n\nForward — new sellers::\nDiscovery → enrichment → scoring → CRM. Driven by SmartScout and queue messages.\nThis path creates records.\n\nBackward — existing contacts::\nEngagement signal → BigQuery segment → call list. Driven by schedules and CRM\ndata. This path creates *attention*, not records. It is where most of the\nbusiness logic now lives, because the CRM already contains far more contacts than\ndiscovery adds.\n\nBoth converge on the same asset: a prioritised, owned, dated call list that a\nhuman works top-down.\n"},{"component":"morpheus","module":"business","path":"business:seller-discovery.adoc","title":"Seller discovery","description":"Which marketplace sellers enter the pipeline, how pagination advances, and how to stop it.","url":"https://docx.payability.com/morpheus/business/seller-discovery.html","navOrder":13,"source":"= Seller discovery\n:description: Which marketplace sellers enter the pipeline, how pagination advances, and how to stop it.\n\nDiscovery is owned by xref:lambdas:enrichment-scraper.adoc[`enrichment-scraper`].\nIt queries SmartScout for sellers matching the ideal customer profile and\npublishes each result to `EnrichmentPipelineQueue` as a\n`SellerRecord.Received` message.\n\nImplementation: `lambdas/enrichment_scraper/events/amazon_discovery.py`,\n`providers/smartscout/provider.py`, `services/config_state.py`,\n`services/queue.py`.\n\n== Kill switch — checked first\n\nBefore any network call, the handler reads `configs.key =\n'enrichment.amazon.disabled'`. If the value is exactly `\"true\"`\n(case-insensitive, whitespace-trimmed) the run logs and returns. Any other\nvalue, or an absent row, means active.\n\n[source,sql]\n----\n-- stop discovery\nINSERT INTO configs (key, value, \"subscriptionId\")\nVALUES ('enrichment.amazon.disabled', 'true', '<subscription-uuid>')\nON CONFLICT (key) DO UPDATE SET value = 'true';\n\n-- resume\nUPDATE configs SET value = 'false' WHERE key = 'enrichment.amazon.disabled';\n----\n\nThis is a database flag, not an environment variable, so it takes effect on the\nnext scheduled run without a deploy.\n\n== Ideal customer profile\n\nFilters are built from `Settings` and passed to the provider as `SellerFilters`.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Setting | Default | Meaning\n\n| `ICP_REVENUE_MIN` | `100000` | Minimum *annual* revenue, USD\n| `ICP_REVENUE_MAX` | `1000000` | Maximum *annual* revenue, USD\n| `ICP_CATEGORIES` | empty | Comma-separated category filter; empty means all categories\n| `MAX_PAGES_PER_RUN` | `1` | Pages fetched per invocation; the event's `detail.max_pages` overrides it\n| `PAGE_SIZE` | `10` | Rows per SmartScout page\n| `SMARTSCOUT_MARKETPLACE` | `US` | SmartScout marketplace header\n|===\n\n[IMPORTANT]\n====\n`ICP_REVENUE_*` are annual figures. `Settings.monthly_revenue_min` and\n`monthly_revenue_max` divide by 12 before the values reach SmartScout, which\nfilters on estimated monthly sales. With the defaults the effective band is\nroughly \\$8.3k–\\$83k per month.\n\nA `model_validator` rejects `min >= max` at import time, so a misconfiguration\nfails the cold start rather than silently returning nothing.\n====\n\n== Pagination across runs\n\nSmartScout results are consumed a page at a time, and the cursor lives in the\ndatabase so consecutive scheduled runs walk forward through the result set instead\nof re-reading page one.\n\n[mermaid]\n....\nsequenceDiagram\n  participant EB as EventBridge\n  participant H as AmazonDiscoveryHandler\n  participant CFG as configs table\n  participant SS as SmartScout\n  participant Q as EnrichmentPipelineQueue\n\n  EB->>H: SellerDiscovery.Amazon\n  H->>CFG: get enrichment.amazon.disabled\n  H->>CFG: get enrichment.amazon.next_start_row\n  H->>SS: search(filters, start_from_row)\n  SS-->>H: (records, next_start_row)\n  H->>CFG: save enrichment.amazon.next_start_row\n  H->>Q: publish records in batches of 10\n....\n\n`fetch_sellers()` returns `(records, next_start_row)`. A returned `0` means the\nfull result set has been consumed and the next run restarts from the top. The\ncursor is saved *before* the records are published, so a publish failure does not\ncause the same page to be re-fetched forever.\n\nReset the cursor to re-walk from the beginning:\n\n[source,sql]\n----\nUPDATE configs SET value = '0' WHERE key = 'enrichment.amazon.next_start_row';\n----\n\n== Field mapping\n\n`SmartScoutSeller` → `SellerRecord`\n(`providers/smartscout/provider.py#_to_seller_record`):\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| SmartScout field | `SellerRecord` field | Notes\n\n| `amazonSellerId` | `seller_id` | The external marketplace id and the natural key\n| `businessName` or `name` | `business_name` | `businessName` preferred, falls back to `name`, then empty string\n| — | `storefront_url` | Constructed by `make_storefront_url(amazonSellerId)`\n| `estimateSales` | `estimated_monthly_revenue` | Defaults to `0.0`; a validator rejects negatives\n| `numberAsins` | `asin_count` | Defaults to `0`; a validator rejects negatives\n| `primarySubCategory`, else `primaryCategoryId` | `primary_category` | Sub-category preferred\n| `startedSellingDate` | `seller_since` | Parsed with `dateutil`; unparseable values are logged at `DEBUG` and dropped\n| `percentFba` | `percent_fba` |\n| `moMGrowth` | `mom_growth` | Month-over-month growth percentage\n| `numberWinningBrands` | `num_brands` |\n| `state`, `country` | `state`, `country` |\n| whole row | `raw` | Full provider payload retained on the message\n|===\n\n`SellerRecord` is `frozen=True` with `str_strip_whitespace=True`. A row that\nfails validation is logged at `ERROR` by `map_seller()` and skipped — one bad row\nnever aborts the page.\n\n== Publishing\n\n`QueueService.publish()` sends `send_message_batch` calls of 10 to\n`EnrichmentPipelineQueue.fifo`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| SQS field | Value\n\n| `MessageBody` | `record.model_dump_json()`\n| `MessageGroupId` | `record.seller_id`\n| `MessageDeduplicationId` | `sha256(seller_id + marketplace)`\n| `MessageAttributes.event_type` | `SellerRecord.Received`\n|===\n\nOne FIFO message group per seller is deliberate: grouping by marketplace would\nserialise the entire pipeline behind two groups, whereas one group per seller lets\nthe scorer run many invocations concurrently while still ordering the messages for\nany single seller.\n\nThe deduplication id is content-derived from the seller identity, so the same\nseller discovered twice inside SQS's five-minute deduplication window is\npublished once.\n\nBatch failures are logged with the failed entries; the run continues.\n\n== SmartScout access\n\nAuthentication is username/password against the SmartScout API, producing a\nsession token that is cached *in the shared Secrets Manager secret*\n(`SMARTSCOUT_TOKEN`, `SMARTSCOUT_TOKEN_EXPIRES_AT`) rather than in memory. This is\nwhy `enrichment-scraper` is the only function with `secretsmanager:PutSecretValue`.\n\nRequests go through `BaseProvider._post_with_retry()`, which handles:\n\n* *Timeouts* — up to `HTTP_MAX_RETRIES` attempts with exponential backoff\n  (`HTTP_RETRY_BASE_SECONDS * 2^n`), then the page is skipped.\n* *401* — one retry after `_on_unauthorized()` invalidates the cached token so\n  fresh credentials are used; a second 401 skips the page.\n* *429* — sleeps for `Retry-After`, or the parsed `X-Rate-Limit-Reset`, then\n  retries without incrementing the attempt counter.\n* *5xx* — logged with a truncated body and the page is skipped.\n* *Proactive throttling* — when `X-Rate-Limit-Remaining` drops to 1 or 0, sleeps\n  until `X-Rate-Limit-Reset` before continuing.\n\nHeaders include a browser fingerprint (`SMARTSCOUT_BROWSER_HEADERS`), a per-request\n`Request-Id` UUID, and `Content-Type: application/json-patch+json`.\n\n== Other marketplaces\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Detail-type | Status | Notes\n\n| `SellerDiscovery.Amazon` | live | SmartScout, the only implemented path\n| `SellerDiscovery.Walmart` | stub | `WalmartDiscoveryHandler` raises `NotImplementedError`. Its EventBridge rule is disabled. Implementation notes are in the module docstring: SmartScout supports other marketplaces via the `X-SmartScout-Marketplace` header.\n| `SellerDiscovery.RunCategory` | stub | `RunCategoryHandler` raises `NotImplementedError`. Intended for ad-hoc single-category runs that override `ICP_CATEGORIES` without waiting for the schedule. Already registered, so only the handler body is missing.\n|===\n\n`sellers.platform` supports `amazon`, `walmart`, and `shopify`; only `amazon` and\n`walmart` are mapped from discovery (`PLATFORM_MAP` in the scorer).\n\n== Current schedule state\n\nThe `AmazonDailyDiscovery` rule (`cron(0 7 * * ? *)` — 2 AM ET) is *commented out*\nin `lambdas/template.yaml`, as is `WalmartDailyDiscovery`. The only live schedule\non this function is `BigQueryContactSync`. Discovery therefore runs only when\ninvoked manually:\n\n[source,bash,subs=attributes+]\n----\naws lambda invoke \\\n  --function-name {sam-stack}-enrichment-scraper \\\n  --payload '{\"detail-type\":\"SellerDiscovery.Amazon\",\"source\":\"enrichment.scraper\",\"detail\":{\"marketplace\":\"amazon\",\"provider\":\"smartscout\",\"max_pages\":5}}' \\\n  --cli-binary-format raw-in-base64-out /dev/stdout\n----\n\nThe downstream `EnrichmentPipelineQueue` SQS trigger on `enrichment-scorer` is\nalso commented out, so published messages accumulate on the queue until it is\nenabled. See xref:infrastructure:schedules.adoc[EventBridge schedules].\n"},{"component":"morpheus","module":"business","path":"business:seller-enrichment.adoc","title":"Seller enrichment","description":"How Morpheus gathers contact intelligence for a discovered seller and writes it back.","url":"https://docx.payability.com/morpheus/business/seller-enrichment.html","navOrder":14,"source":"= Seller enrichment\n:description: How Morpheus gathers contact intelligence for a discovered seller and writes it back.\n\nEnrichment turns a bare marketplace row — a seller id, a business name, a revenue\nestimate — into something a salesperson can act on: a website, a LinkedIn page,\nnamed people with email addresses and phone numbers, and a HubSpot company match\nif one already exists.\n\nOwned by xref:lambdas:enrichment-scorer.adoc[`enrichment-scorer`].\nImplementation: `lambdas/enrichment_scorer/events/base_enrichment.py`,\n`agents/seller_enrichment_agent.py`, `agents/prompts.py`.\n\n== The four-step pipeline\n\nFor every `SellerRecord.Received` message, `SellerReceivedHandler` runs:\n\n[mermaid]\n....\nflowchart TD\n  A[\"1. Upsert seller row\\nkeyed on sellerId\\nCOMMITS\"] --> B{\"enrichedAt set?\"}\n  B -->|yes| D{\"payoutPainScore set?\"}\n  B -->|no| C1[\"2. _enrich()\\nAI agent + Tavily + HubSpot MCP\"]\n  C1 --> C2[\"3. _apply_enrichment()\\nseller updates + contact upserts\\nlifeCycleStage = enriched\\nCOMMITS\"]\n  C2 --> C3[\"publish Seller.CrmSync\"]\n  C3 --> D\n  D -->|yes| E[\"done\"]\n  D -->|no| F1[\"4. _score()\\nsecond AI pass, no tools\"]\n  F1 --> F2[\"_apply_score()\\npayoutPainScore\\nCOMMITS\"]\n  F2 --> E\n....\n\n[IMPORTANT]\n====\n*Step 1 commits before any AI runs.* Steps 2, 3, and 4 are each wrapped in\n`try`/`except`: a failure is logged at `ERROR` and the handler returns normally,\nso the SQS message is acked. The seller row is never lost because an LLM call\nfailed, and a failed enrichment is picked up later by the\n`Seller.EnrichPending` backfill.\n====\n\n=== Step 1 — persist\n\n`_upsert_seller()` writes via `SellerRepository.upsert_by_seller_id()`\n(`ON CONFLICT (sellerId) DO UPDATE`), overwriting all mutable fields.\n\nTwo clamps matter:\n\n* `estimateSales`, `numberAsins`, and `numBrands` are clamped to\n  `INT32_MAX` (2,147,483,647). PostgreSQL `INTEGER` is 32-bit signed and outlier\n  sellers such as Amazon Retail exceed it; clamping beats failing the upsert.\n* `PLATFORM_MAP` and `DATASOURCE_MAP` translate the scraper's `Marketplace` and\n  `DataSource` enums into the database's `SellerPlatform` and `SellerDatasource`.\n  `jungle_scout` maps to `smartscout`; `walmart_scraper` maps to `manual`.\n\nAfter the upsert the row is refreshed inside the session and `enrichedAt`,\n`payoutPainScore`, and `note` are touched so they remain readable on the detached\nobject after the session closes.\n\n=== Step 2 — enrich\n\n`run_seller_enrichment()` builds a Claude agent, cached per container with\n`@lru_cache(maxsize=1)`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Model | `AgentModelIds.CLAUDE_HAIKU_4_5` — cheap and fast; no deep reasoning needed\n| `max_tokens` | 2048\n| `max_turns` | 6\n| Structured output | `SellerEnrichmentResult`\n| Tools (factory) | `create_tavily_mcp(...)`, `create_hubspot_mcp(...)`\n|===\n\nTools are supplied as a `lambda` factory so each `run()` opens fresh MCP\ntransports. MCP clients hold an open HTTP session and cannot be reused across\nagent invocations.\n\nThe prompt receives: business name (falling back to the seller id), marketplace,\nseller id, storefront URL, primary category, and a location string assembled from\nstate and country (`\"Unknown\"` when both are absent). Prompts live in\n`agents/prompts.py`.\n\nStructured result (`models/enrichment.py`):\n\n[source,python]\n----\nclass SellerContact(BaseModel):\n    name: str | None                  # person's name, or None if only a role was found\n    role: str | None                  # \"Founder\", \"CEO\", \"Sales\", \"Support\", …\n    email: str | None\n    phone_number: str | None          # validator strips all non-digits\n    hubspot_contact_id: str | None\n\nclass SellerEnrichmentResult(BaseModel):\n    website_url: str | None\n    linkedin_url: str | None\n    hubspot_company_id: str | None\n    hubspot_found: bool = False\n    contacts: list[SellerContact] = []\n    confidence: str = \"low\"           # \"high\" = 3+ verified facts, \"medium\" = 1–2, \"low\" = minimal\n    notes: str | None                 # source summary + anything that did not fit a field\n----\n\nToken usage (including cache read/write) is logged per seller.\n\n=== Step 3 — write back\n\n`_apply_enrichment()` opens one session and, inside it:\n\n. Loads the seller by `sellerId`. A missing row logs a warning and returns.\n. Updates the seller: `lifeCycleStage = enriched`, `enrichedAt = now`, plus\n  `website`, `linkedinUrl`, `hubspotCompanyId`, and `note` — each written only when\n  the agent produced a value, so an empty result never blanks an existing field.\n. Upserts each contact through `ContactRepository.upsert_for_seller()`.\n. Snapshots the fields needed for the CRM event *while the session is open*, since\n  ORM objects detach after commit.\n\nContact rules:\n\n* A contact with no email, no phone number, and no HubSpot contact id is *skipped*\n  — there is nothing to reach them by, and nothing to deduplicate on.\n* An individual contact upsert failure is logged at `WARNING` and counted as\n  skipped; the remaining contacts still persist.\n* `split_name()` splits the agent's `name` on the first whitespace: first token is\n  `firstName`, everything after is `lastName`.\n* `source` is `\"hubspot\"` when the agent returned a `hubspot_contact_id`,\n  otherwise `\"manual\"`.\n\nThe summary line reports `upserted=` and `skipped=` counts.\n\n=== Step 4 — score\n\nSee xref:payout-pain-score.adoc[Payout-pain score]. `_apply_score()` opens its own\nshort-lived session so a scoring failure can never roll back the enrichment\ncommit.\n\n== CRM publication\n\nWhen `CRM_SYNC_QUEUE_URL` is configured, `_build_crm_sync_event()` assembles a fat\n`SellerSyncEvent` from the in-session ORM objects and `_publish_crm_sync()` sends\nit to `CrmSyncQueue` after the transaction commits:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| SQS field | Value\n\n| `MessageGroupId` | `seller_id`\n| `MessageDeduplicationId` | `sha256(\"crm-sync-\" + seller_id)`\n|===\n\nPublishing is fire-and-forget: a failure is logged at `WARNING` and the enrichment\ncommit stands. The event carries company fields plus a `ContactSyncPayload` per\nupserted contact — including `internal_id` so `morpheus-hubspot` can write\n`hubspotContactId` back — and `life_cycle_stage = enriched`. See\nxref:crm-sync.adoc[CRM sync].\n\n== Skip conditions\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Condition | Effect\n\n| `seller.enrichedAt` is not null\n| Enrichment is skipped with an `INFO` log. Scoring is still evaluated.\n\n| `seller.payoutPainScore` is not null\n| Scoring is skipped with an `INFO` log.\n\n| Enrichment raised\n| Logged at `ERROR` with `exc_info`. Scoring still runs, using `seller.note` for the enrichment context instead of the fresh result.\n\n| Contact has no email, phone, or HubSpot id\n| Contact skipped, counted in the summary line.\n\n| `CRM_SYNC_QUEUE_URL` empty\n| No CRM event is built or published. Database state is unaffected.\n|===\n\n== Backfill\n\nSellers that missed enrichment — because the AI step failed, or because they were\ningested before the agent existed — are recovered by the\n`Seller.EnrichPending` scheduled handler\n(`lambdas/enrichment_scorer/events/enrich_pending.py`).\n\n* Selects up to 10 sellers per run via `SellerRepository.get_pending_enrichment()`:\n  `lifeCycleStage = new` and `enrichedAt IS NULL`.\n* Runs the same `_enrich()` → `_apply_enrichment()` → `_score()` → `_apply_score()`\n  sequence, each in its own `try`/`except`.\n* One seller's failure logs an `ERROR` and the loop moves on.\n\nIts EventBridge rule (`EnrichPendingBackfill`) is currently commented out in\n`lambdas/template.yaml`; invoke it manually or re-enable the rule. See\nxref:infrastructure:schedules.adoc[EventBridge schedules].\n\n== Cost and latency shape\n\nThe enrichment pass is the most expensive step in the platform: a reasoning model\nplus up to six tool turns across two remote MCP servers. Controls in place:\n\n* Haiku 4.5 rather than a frontier model.\n* `max_turns = 6` and `max_tokens = 2048`.\n* `ReservedConcurrentExecutions: 5` on the function.\n* `Timeout: 120` seconds, below the queue's 180-second visibility timeout.\n* Prompt and tool caching enabled for Anthropic models by\n  `ClaudeAgentService` — see xref:libs:morpheus-llm.adoc#caching[prompt caching].\n* `BedrockTokenAlarm` fires above 5M input tokens per hour.\n"},{"component":"morpheus","module":"business","path":"business:payout-pain-score.adoc","title":"Payout-pain score","description":"The eight weighted signals behind sellers.payoutPainScore, and how the score is produced.","url":"https://docx.payability.com/morpheus/business/payout-pain-score.html","navOrder":15,"source":"= Payout-pain score\n:description: The eight weighted signals behind sellers.payoutPainScore, and how the score is produced.\n\n`sellers.payoutPainScore` is an integer 0–100 estimating how acutely a\nmarketplace seller needs faster access to their payouts. Higher means stronger\ncash-flow pressure, which means a better prospect for payout financing.\n\nIt is produced by the *second* AI pass in the enrichment pipeline — a pure\nreasoning call with no tools, reading only structured seller data plus the notes\nthe first pass gathered.\n\nImplementation: `lambdas/enrichment_scorer/agents/seller_scoring_agent.py`,\nprompts in `agents/prompts.py`, result model in `models/enrichment.py`.\n\n== Agent configuration\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Model | `AgentModelIds.CLAUDE_HAIKU_3`\n| `max_tokens` | 4096\n| `max_turns` | 3\n| Tools | none — `tools=lambda: []`\n| Structured output | `PayoutPainScoreResult`\n|===\n\n[NOTE]\n====\n`max_tokens` is 4096, not 1024. The model writes a per-signal reasoning block of\nroughly 1,000–2,000 tokens before calling the structured-output tool; a tighter\nbudget produced `MaxTokensReachedException`.\n====\n\nResult:\n\n[source,python]\n----\nclass PayoutPainScoreResult(BaseModel):\n    payout_pain_score: int   # 0–100\n    reasoning: str           # one paragraph explaining each signal's contribution\n----\n\nOnly `payout_pain_score` is persisted, to `sellers.payoutPainScore`. The reasoning\nis logged at `DEBUG` and then discarded.\n\n== Inputs\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Field | Source\n\n| `seller_id` | `sellers.sellerId`\n| `business_name` | `businessName`, falling back to `name`\n| `platforms` | The marketplace string; multiple platforms are comma-separated\n| `primary_category` | `primaryCategory`\n| `estimated_monthly_revenue` | `estimateSales`, formatted as `$N/month`\n| `mom_growth` | `moMGrowth`, formatted to one decimal place with `%`\n| `asin_count` | `numberAsins`\n| `started_selling_date` | `startedSellingDate` as an ISO date\n| `today_date` | `date.today().isoformat()` — needed for the seasonal signal\n| `enrichment_notes` | `SellerEnrichmentResult.notes` from the first pass, or `sellers.note` when scoring runs without a fresh enrichment\n|===\n\nAny missing value is rendered as `\"Unknown\"` and the prompt instructs the model to\ntreat it as the lowest-scoring tier for that signal.\n\n== The eight signals\n\nTotal weight is 100. Each signal is evaluated independently and the weighted\nscores are summed.\n\n=== 1. Estimated monthly revenue — weight 20\n\nThe primary driver: how much cash is locked in transit.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Band | Points | Rationale\n\n| `< $1k` or unknown | 0 | Nothing meaningful locked up\n| `$1k–$10k` | 8 | Small; low absolute pressure\n| `$10k–$80k` | 20 | Sweet spot — meaningful cash, limited access to credit\n| `$80k–$500k` | 15 | Larger; more likely to have financing options\n| `> $500k` | 10 | Enterprise scale; institutional credit available\n|===\n\nThe curve is deliberately non-monotonic. Very large sellers have banks.\n\n=== 2. Revenue growth trend — weight 15\n\nGrowth outruns working capital faster than anything else.\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| MoM growth | Points\n\n| Unknown or negative | 0\n| 0–5% | 5\n| 5–15% | 10\n| `> 15%` | 15\n|===\n\n=== 3. Seasonal timing — weight 15\n\nCash-flow pressure peaks 60–90 days before a high-volume season, when inventory\nmust be bought. Windows: Q4 (Oct–Dec), Prime Day (typically July),\nback-to-school (August). Scored relative to `today_date`.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Distance to peak | Points | Rationale\n\n| Outside all windows | 0 |\n| 90–120 days | 8 | Early build-up\n| 60–90 days | 15 | Maximum pressure window\n| 30–60 days | 10 | Late but still relevant\n| `< 30 days` | 5 | Too close — inventory is already ordered\n|===\n\nThis is the only signal whose value changes without the seller changing, so the\nsame seller re-scored in a different month can legitimately land on a different\ntotal.\n\n=== 4. ASIN / SKU count — weight 10\n\nMore product lines means more inventory to fund at once.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Count | Points | Rationale\n\n| Unknown or 0 | 0 |\n| 1–10 | 3 |\n| 11–50 | 7 |\n| 51–200 | 10 |\n| `> 200` | 8 | Likely dropship or wholesale; lower pressure per ASIN\n|===\n\n=== 5. Primary category — weight 10\n\nHigh cost-of-goods, long replenishment cycles.\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Category | Points\n\n| Unknown or services | 0\n| Books, media, toys | 4\n| Beauty, grocery, pet | 6\n| Home goods, outdoors, auto | 8\n| Electronics, supplements, industrial, health | 10\n|===\n\n=== 6. Seller tenure — weight 10\n\nEstablished enough to have real revenue, too small for institutional credit.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Tenure | Points | Rationale\n\n| Unknown or `< 6 months` | 2 | Too new, unproven\n| 6–12 months | 6 |\n| 1–3 years | 10 | Sweet spot\n| 3–7 years | 7 |\n| `> 7 years` | 4 | Likely has established credit lines\n|===\n\n=== 7. Multi-marketplace presence — weight 10\n\nEach marketplace holds funds independently, so selling on two doubles the delay\nproblem.\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Presence | Points\n\n| Single marketplace | 0\n| Two or more | 10\n|===\n\n=== 8. Trigger event — weight 10\n\nActive stress signals found in the enrichment notes or inferable from context.\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Signal | Points\n\n| None detected | 0\n| One weak signal (for example a new product launch) | 5\n| Strong signal (payment hold, rapid scaling, suspension lifted, large purchase order) | 10\n|===\n\nThis is the only signal that depends on the first AI pass. A seller scored without\nenrichment notes can score at most 90.\n\n== Interpreting a score\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Range | Meaning\n\n| 70–100 | Almost certainly an acute cash-flow problem: revenue large enough to lock meaningful capital, growth outpacing working capital, external signals confirming pressure\n| 30–70 | The expected range for most sellers\n| 0–30 | Little evidence of cash-flow stress\n|===\n\nThe prompt states this distribution explicitly, which anchors the model away from\nclustering everything at one end.\n\n== Where the score is used\n\nWritten to `sellers.payoutPainScore` and consumed by the wider platform for\nprioritisation — for example the campaign thresholds described in\nxref:plans:enrichment-agent.adoc[the enrichment agent plan] (`> 40` enrol in\nemail, `> 70` call first).\n\n[NOTE]\n====\nThe score is *not* an input to any re-engagement segment in this repository. Those\nsegments prioritise on engagement recency and call history, not on payout pain.\nSee xref:re-engagement.adoc[Re-engagement segments].\n====\n\n== Failure and backfill\n\nScoring is best-effort. `_apply_score()` runs in its own short-lived session so a\nfailure cannot roll back the enrichment commit, and every exception is caught and\nlogged at `ERROR`.\n\nSellers enriched before scoring existed, or whose scoring pass failed, are\nrecovered by the `Seller.ScorePending` handler\n(`lambdas/enrichment_scorer/events/score_pending.py`):\n\n* Selects up to 10 sellers via `get_pending_scoring()`: `enrichedAt IS NOT NULL`\n  and `payoutPainScore IS NULL`.\n* Runs only the scoring pass, using `seller.note` as the enrichment context.\n* Each seller is independent; one failure logs and the loop continues.\n\nIts EventBridge rule (`ScorePendingBackfill`) is currently commented out in\n`lambdas/template.yaml`.\n\n== Changing the formula\n\nThe formula lives entirely in `SELLER_SCORING_PROMPT` in\n`lambdas/enrichment_scorer/agents/prompts.py`. Changing it changes future scores\nonly — existing rows are not recomputed, and `get_pending_scoring()` skips any row\nthat already has a score. To rescore a cohort, null the column for those sellers\nand let the backfill pick them up:\n\n[source,sql]\n----\nUPDATE sellers SET \"payoutPainScore\" = NULL\nWHERE \"enrichedAt\" IS NOT NULL AND \"primaryCategory\" = 'Electronics';\n----\n\nThen invoke `Seller.ScorePending` repeatedly (10 sellers per run) or raise\n`_BATCH_SIZE` in the handler.\n"},{"component":"morpheus","module":"business","path":"business:crm-sync.adoc","title":"CRM sync","description":"How Morpheus data becomes HubSpot records — object mapping, match priority, write-back, and activity logging.","url":"https://docx.payability.com/morpheus/business/crm-sync.html","navOrder":16,"source":"= CRM sync\n:description: How Morpheus data becomes HubSpot records — object mapping, match priority, write-back, and activity logging.\n\nHubSpot is the system of record for the sales team. Morpheus treats it as an\nintegration target with exactly one writer:\nxref:lambdas:morpheus-hubspot.adoc[`morpheus-hubspot`]. Everything else publishes\nan event to `CrmSyncQueue`.\n\nImplementation: `lambdas/morpheus_hubspot/events/`, with every HubSpot API call\nisolated in the xref:libs:pyb-hubspot.adoc[`pyb-hubspot`] library.\n\n== Object mapping\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Morpheus | HubSpot | Notes\n\n| `sellers` row | Company | A seller is a *business*. `hubspotCompanyId` is stored on the seller row.\n| `contacts` row | Contact | A person. `hubspotContactId` is stored on the contact row.\n| `Activity` row | Engagement (Email, Call, Note, Task, Meeting) | Correlated by `Activity.externalId` ↔ `engagement_id`.\n| `follow_up_tasks` row | Task | `hubspotTaskId` on the task row.\n| `Call` row | Call engagement | `hubspotEngagementId` on the call row.\n| `sales_reps` row | Owner | `hubspotOwnerId` — the CRM *owner* id, not the settings user id.\n| — | Contact list | Per-rep dynamic call list; id stored in `sales_reps.hubspotCallSegmentId`.\n|===\n\n[WARNING]\n====\n`hubspot_owner_id` must be the `id` from `GET /crm/v3/owners`. The number in the\nHubSpot settings URL (`.../users/user/12345/...`) is a *user* id and is not\nguaranteed to match. A wrong id often still creates the Task, but assigns it to\nthe wrong person — usually the integration user. This is called out in\n`pyb_hubspot/tasks.py`.\n====\n\n== Authentication\n\nTwo modes, in priority order:\n\n. *Private App token* — `HUBSPOT_PRIVATE_APP_TOKEN` (`pat-…`). Static, no refresh.\n  Preferred.\n. *OAuth triple* — `HUBSPOT_CLIENT_ID`, `HUBSPOT_CLIENT_SECRET`,\n  `HUBSPOT_REFRESH_TOKEN`. Exchanged for a short-lived access token.\n\n`HubspotSecrets` validates at load time that at least one complete mode exists and\nraises otherwise, so a misconfigured secret fails the cold start loudly rather\nthan at the first API call.\n\nBoth modes produce the same `HubSpotSyncClient`. Endpoints not covered by the\nofficial SDK (Communication Preferences, custom events) go through\n`HubSpotHttpClient`, which carries the same bearer token.\n\n== Seller sync — `Seller.CrmSync`\n\nConsumes the fat `SellerSyncEvent` published by\nxref:lambdas:enrichment-scorer.adoc[`enrichment-scorer`]. No database read is\nneeded to perform the sync; the database is only written at the end.\n\n[mermaid]\n....\nsequenceDiagram\n  participant Q as CrmSyncQueue\n  participant H as SellerCrmSyncHandler\n  participant HS as HubSpot\n  participant DB as PostgreSQL\n\n  Q->>H: Seller.CrmSync (company fields + contacts[])\n  H->>HS: upsert_company()\n  alt company already existed\n    H->>HS: log_note() on the Company\n  end\n  loop each contact\n    H->>HS: upsert_contact() + associate with Company\n    alt contact already existed\n      H->>HS: log_note() on the Contact\n    end\n  end\n  H->>DB: sellers.hubspotCompanyId\n  H->>DB: contacts.hubspotContactId (per contact)\n....\n\n=== Company match priority\n\n`pyb_hubspot/sync/company.py` searches in this order and stops at the first hit:\n\n. `hubspot_company_id` from the event — skips searching entirely.\n. The custom `amazon_seller_id` property — catches companies created by an earlier\n  sync whose id was never written back.\n. `name` — catches companies created outside Morpheus.\n\nNo match creates a new Company. The function returns\n`UpsertResult(id, created)` so the caller knows whether to log an update Note.\n\n=== Contact match priority\n\n`pyb_hubspot/sync/contact.py`:\n\n. `hubspot_contact_id` from the event.\n. `email` — HubSpot's own primary deduplication key for Contacts.\n\nNo match creates a new Contact, which is then associated with the Company using\nthe v4 Associations API. A payload with no email and no existing id returns\n`None` — there is nothing to deduplicate on.\n\n=== Isolation\n\nA single contact failure is logged and skipped. It does not block the remaining\ncontacts, and it does not block the Company write-back.\n\n== Contact property updates — `Contact.Update`\n\nThe generic Contact PATCH path. `hubspot_contact_id` is required; there is no CRM\nsearch and no local `contacts` lookup, which makes it cheap and predictable.\n\nGuards, each logging at `ERROR` and returning without raising:\n\n* `subscription_id` does not match the function's configured tenant.\n* `properties` is empty (also rejected by a `model_validator` on the event).\n* `hubspot_contact_id` is blank after trimming.\n\nOptional write-back: when `target_phone_number_id` is set, the corresponding\n`target_phone_numbers` row is stamped after a successful PATCH. This is how\nxref:phone-intelligence.adoc[phone line intelligence] closes its loop.\n\nThis is the event every re-engagement producer in `phone-agent` uses to set\n`call_list_date`, `call_priority`, and `call_assigned_to`.\n\n== Activities\n\n`Crm.NewActivity` — create a logged Email object::\nValidates `properties` against `EmailActivityCreateProperties` (which defaults\n`hs_timestamp` to now when omitted, since HubSpot requires it on create), then\ncreates the Email and associates it with the Contact and/or Company. At least one\nassociation target is required; without one the handler logs a warning and\nreturns. Only `activity_kind = \"email\"` is supported — anything else raises.\n+\nTwo follow-on behaviours:\n+\n* When `hs_email_direction` is `INCOMING_EMAIL` and `hubspot_owner_id` is present,\n  a follow-up Task is created and assigned to that owner.\n* An `AgentEmail` custom behavioural event is sent afterwards, carrying the\n  optional `campaign` value. The `campaign` property lives on the custom event,\n  not on the Email object.\n\n`Crm.UpdateActivity` — PATCH an existing object::\nValidates against `EmailActivityUpdateProperties` (partial fields, no auto\ntimestamp) and PATCHes by `hubspot_object_id`. Only `object_type = \"emails\"` is\nsupported; anything else raises.\n\n`Crm.CustomEvent` — behavioural events::\nOnly whitelisted kinds are accepted. Today that is `agent_email`, sent to\n`POST /events/v3/send` as `pe{hubspot-portal}_agentemail` (overridable per message\nwith `hubspot_event_name_override`). Properties: `status` (one of `opened`,\n`clicked`, `bounced`, `complained`, `unsubscribed`, `delivered`, `replied`,\n`sent`), plus optional `campaign` and `link_url`. The whitelist means an unknown\n`custom_event_kind` fails validation before any API call.\n\nAssociation type ids used inline (HubSpot-defined): Email→Contact `198`,\nEmail→Company `186`, Note→Contact `202`, Note→Company `190`.\n\n== Notes\n\n`log_note()` (`pyb_hubspot/engagements.py`) creates a CRM Note associated with a\nCompany and/or Contact in a single API call. Morpheus logs a Note whenever a sync\n*updated* an existing record, so the CRM timeline shows that automation touched\nit — a create needs no note because the record's existence is the evidence.\n\n== Tasks — `Crm.TaskCreate`\n\nCreates a HubSpot Task first, then mirrors it locally. Order matters: the HubSpot\nid is needed to populate `follow_up_tasks.hubspotTaskId`.\n\nRequirements:\n\n* At least one of `contact_id` (internal UUID) or `hubspot_contact_id`, enforced by\n  a `model_validator`.\n* `hubspot_owner_id` is mandatory — used both to assign the Task and to look up\n  the matching `sales_reps` row for the local mirror.\n* `task_type` maps `call`/`email`/`todo` → HubSpot `CALL`/`EMAIL`/`TODO`.\n* `subject` is required (`hs_task_subject`); `body` is optional (`hs_task_body`);\n  `due_at` becomes `hs_timestamp`; `priority` defaults to `MEDIUM`.\n\nSee xref:follow-up-tasks.adoc[Follow-up tasks and escalation] for what happens\nnext.\n\n[#deals]\n== Deals — `Crm.DealUpdate`\n\nSets a HubSpot Deal's stage, in one of two modes:\n\n* `hubspot_deal_id` present — update exactly that deal.\n* `hubspot_contact_id` present — find every *open* deal for the contact via\n  `get_open_deal_ids_for_contact()` and update each. No open deals logs\n  `skip_reason=no_open_deals` and returns.\n\nA `model_validator` requires at least one identifier. When `note` is set it is\nlogged on each deal *before* the stage change, so the timeline reads in causal\norder. A per-deal update failure is logged and the loop continues.\n\n== Communication preferences — `Contact.Unsubscribe`\n\nUpdates HubSpot email subscription types through\n`+POST /communication-preferences/…/statuses/{email}+` — a REST endpoint not covered\nby the SDK, so it goes through `HubSpotHttpClient`.\n\nSubscriber email resolution:\n\n. `contact_email` from the event, when set.\n. Otherwise `get_by_id(hubspot_contact_id)` to read the email from the CRM.\n\nNeither present is a validation error on the event model. Each\n`SubscriptionPreferenceUpdate` carries a numeric `hubspot_subscription_type_id`\n(validated as digits) and a status of `SUBSCRIBED`, `UNSUBSCRIBED`, or\n`NOT_SPECIFIED`. `legal_basis` and `legal_basis_explanation` are passed through\nwhen the portal's data-privacy settings require them. HubSpot may require one POST\nper subscription type, so the list is applied item by item.\n\n== HubSpot id backfill\n\nMany contacts arrive without a `hubspotContactId` — imported, created by the web\napplication, or synced from BigQuery. Two paths resolve them.\n\n`Contact.ResolveHubSpotIds` (SQS, on demand)::\nLoads contacts missing the id via\n`ContactRepository.list_missing_hubspot_contact_id()`, resolves each with\n`resolve_hubspot_contact_id_phone_first()`, and writes back. `batch_limit` on the\nevent overrides `CONTACT_HUBSPOT_RESOLVE_BATCH_SIZE`. The payload carries no PII —\nthe handler loads candidates itself.\n\n`Hubspot.ContactResolve` (scheduled, every 30 minutes)::\nThe same idea with a wider search. It tries the contact's own `email`, `phone`, and\n`mobilePhone`, then every related `target_phone_numbers` row (against both the\nHubSpot `phone` and `mobilephone` properties), then every related `target_emails`\nrow. On the first match it writes `hubspotContactId`, backfills `firstName` and\n`lastName` from HubSpot where the local values are null, and stamps\n`hubspotLastSyncedAt`. On no match it stamps `hubspotLastSyncedAt` anyway, which\ndeprioritises that contact in subsequent runs so the batch keeps moving through\nnew candidates.\n\n== Inbound: HubSpot → Morpheus\n\nChanges made by humans in HubSpot arrive as `Crm.HubSpotWebhook` events on\n`CrmSyncQueue` (relayed by an external forwarder). `CrmHubSpotWebhookHandler` opens\none session and one CRM client for the whole batch and dispatches each inner event\nthrough an ordered processor registry: the first processor whose `applies_to()`\nreturns `True` handles it exclusively.\n\nToday one processor is registered: `NewsletterOptOutProcessor`, matching\n`contact.propertyChange` on `hs_email_optout_<subscriptionTypeId>` where the id is\n`44467216`. See xref:email-engagement.adoc#optouts[opt-outs] and\nxref:events:hubspot-webhooks.adoc[HubSpot webhooks].\n\n== Rate limits\n\nThe HubSpot MCP transport used by AI agents retries HTTP 429 with exponential\nbackoff — up to 4 attempts, base 1 s doubling, capped at 30 s per sleep, honouring\n`Retry-After` when present (`morpheus_llm/tools/builtin/hubspot.py`).\n\nDirect SDK calls from `morpheus-hubspot` do not add retry logic; the function\nrelies on `MaximumConcurrency: 10` on the SQS trigger plus `BatchSize: 1` to keep\nrequest rates modest, and on the DLQ to capture anything that still fails.\n"},{"component":"morpheus","module":"business","path":"business:re-engagement.adoc","title":"Re-engagement segments","description":"The five segments that put a previously contacted person back on a call list, with each membership rule and priority formula.","url":"https://docx.payability.com/morpheus/business/re-engagement.html","navOrder":17,"source":"= Re-engagement segments\n:description: The five segments that put a previously contacted person back on a call list, with each membership rule and priority formula.\n\nRe-engagement is the mechanism that decides *who deserves a call today* out of a\nCRM containing far more contacts than any team can work. Each segment watches for\none kind of fresh signal, scores urgency, and stamps the contact onto today's call\nlist.\n\nEvery segment follows the same shape:\n\n[mermaid]\n....\nflowchart LR\n  A[\"BigQuery view\\nmembership + priority_score\"] --> B[\"Scheduled handler\\nPython suppression\"]\n  B --> C[\"Ownership decision\\nkeep owner or round-robin\"]\n  C --> D[\"HubSpot contact properties\\ncall_list_date, call_priority,\\ncall_assigned_to, outreach_list_reason\"]\n....\n\nThe split is deliberate: *set logic and scoring live in SQL* (a view can be\nchanged and inspected without a deploy), *identity, ownership, and the same-day\nguard live in Python* (they need the operational database).\n\n== The five segments\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| Segment | Routing key | Lambda | Signal\n\n| xref:#email-click[Email click]\n| `Hubspot.EmailClickReEngagement`\n| morpheus-hubspot\n| Clicked a link in a campaign email\n\n| xref:#supplier[Supplier account created]\n| `Hubspot.SupplierAccountReEngagement`\n| morpheus-hubspot\n| Created a Payability account but stalled at \"appointment scheduled\"\n\n| xref:#no-show[Unattended meeting]\n| `Hubspot.UnattendedMeetingReEngagement`\n| morpheus-hubspot\n| Booked a meeting, did not attend, then went quiet\n\n| xref:#lost-deal[Lost or stalled deal]\n| `Hubspot.LostDealReEngagement`\n| morpheus-hubspot\n| Deal is outside an active stage and was not hard-disqualified\n\n| xref:#campaign[Amazon seller campaign]\n| `Phone.CampaignTargetReEngagement`\n| phone-agent\n| AI dialer reached an Amazon seller campaign target\n|===\n\nThe four `morpheus-hubspot` segments PATCH HubSpot directly. The `phone-agent`\nsegment publishes a `Contact.Update` event to `CrmSyncQueue` instead — same end\nresult, one hop later.\n\n== Properties written\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| HubSpot contact property | Value\n\n| `call_list_date`\n| Today's date in US Eastern, `YYYY-MM-DD`. Stamping it is what puts the contact on today's list.\n\n| `call_priority`\n| `\"1\"`–`\"4\"`; 4 is most urgent. Either a direct value from the view or a rounded `priority_score`.\n\n| `call_assigned_to`\n| The owner id the call is routed to.\n\n| `hubspot_owner_id`\n| Written *only* when the contact was reassigned, so an existing real owner is never overwritten.\n\n| `outreach_list_reason`\n| Which segment enrolled the contact, so reps and reporting can see the source.\n|===\n\nReason strings (from each function's `utils/constants.py`):\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Segment | `outreach_list_reason`\n\n| Email click | `Email click re-engagement`\n| Supplier account | `Supplier account re-engagement`\n| Unattended meeting | `Unattended meeting re-engagement`\n| Lost deal | `Lost deal re-engagement` (defined but *commented out* in the handler)\n| Amazon seller campaign | `Amazon seller campaign re-engagement`\n|===\n\n== Score-to-priority conversion\n\nThree segments return a float `priority_score` (roughly 0–4) that the handler\nrounds and clamps:\n\n[source,python]\n----\ndef _score_to_call_priority(value) -> str:\n    score = float(value)                       # unparseable → \"1\"\n    rounded = math.floor(score + 0.5)          # half-up: 2.5 → 3\n    return str(max(1, min(4, rounded)))\n----\n\n`math.floor(x + 0.5)` is used rather than `round()` because Python's `round()` is\nbanker's rounding, which would send 2.5 down to 2.\n\n[#email-click]\n== Email click\n\nHandler: `lambdas/morpheus_hubspot/events/scheduled/email_click_reengagement.py`\nView: `bigquery/v_email_clicked_re_engagement_summary.sql`\n\n=== Membership (SQL)\n\nAn `email_campaign_targets` row where:\n\n* `clickedat IS NOT NULL` — they clicked\n* `unsubscribedat IS NULL` — they have not opted out\n* the resolved HubSpot contact has `property_deal_id IS NULL` — no existing deal\n* the email is not in `blocked_emails`\n* the query wrapper adds `WHERE deal_id IS NULL` and `LIMIT 5000`\n\nOne row per email address, keeping the most recent click\n(`QUALIFY ROW_NUMBER() … PARTITION BY ect.email ORDER BY ect.clickedat DESC`).\n\n[IMPORTANT]\n====\nHubSpot contact resolution is by *email*, not by\n`outreach_app.contacts.hubspotcontactid`. That mirror column is frequently null or\nstale at query time, and keying the joins on it let rows through unlinked — the\ncall, task, and blocked-disposition joins all missed, and Python then resolved the\nid downstream and stamped contacts that should have been suppressed. The join is\nan `INNER JOIN` on email: a target with no matching HubSpot contact cannot be\nenrolled or checked, so it is excluded.\n====\n\n`blocked_emails` drops any email that has *ever* had a call with a blocked\ndisposition, across all HubSpot contacts sharing that address — robust to\nduplicate or misaligned contact ids.\n\nThe blocked set is seven disposition GUIDs. Four are labelled in the view — Wrong\nNumber, Do Not Call, Not Qualified, Not a Seller — and three carry a\n`⚠ confirm label` comment because their portal labels have not been verified. The\nauthoritative list is the `blocked_emails` CTE in the view; the same seven GUIDs\nappear in the `bad_disposition_calls` CTE of the no-show and lost-deal views.\n\n=== Priority (SQL)\n\nFreshness of the click, in US Eastern days:\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Days since click | `priority`\n\n| 0–1 (today or yesterday) | 4\n| 2–6 | 3\n| 7+ | 2\n|===\n\nThe handler reads this column directly — no score conversion — defaulting to\n`\"2\"` when absent.\n\n=== Suppression (Python)\n\nA row is dropped when the last call engagement was made by a *human* owner within\n`EMAIL_CLICK_REENGAGEMENT_STALE_DAYS` (2 days):\n\n[source,python]\n----\nowner = row[\"engagement_owner_id\"]\nif not owner or owner == DIALER_FALLBACK_HUBSPOT_OWNER_ID:   # \"86817215\" = AI\n    return False                                            # AI calls don't suppress\nreturn (now - last_call_timestamp) < timedelta(days=2)\n----\n\nAn AI call does not suppress a human follow-up; that is the entire point of the\nsegment.\n\n=== Identity\n\nThis is the only segment that needs a database lookup. The view returns the\ninternal `contactid` (a UUID), so the handler loads the `contacts` row and uses\n`contact.hubspotContactId`. A missing contact or a blank id is counted in\n`skipped_no_hs_id` and the row is dropped.\n\n=== Ownership\n\nReassign when the current owner is blank, is the AI fallback owner, or is not an\nactive `sales_reps` row for the subscription. Otherwise the existing owner is left\nuntouched and no `call_assigned_to` is set by this segment.\n\n[#supplier]\n== Supplier account created\n\nHandler: `lambdas/morpheus_hubspot/events/scheduled/supplier_account_reengagement.py`\nView: `bigquery/v_supplier_account_created_reeng.sql`\n\nSomeone signed up for Payability and then stopped moving.\n\n=== Membership (SQL)\n\nA HubSpot Deal where:\n\n* `created_date > CURRENT_TIMESTAMP() - 90 days` (the query wrapper narrows this\n  further to 14 days on `account_created_date`)\n* `deal_stage = 'appointmentscheduled'`\n* `pipeline = 'default'`\n* at least one associated contact vid\n* no call with a bad disposition (`bad_disposition_calls = 0`)\n\nDeals are deduplicated to their latest exported version first, so stage and\nlost-reason filters apply to the *current* deal. The first associated contact vid\nis used. Ordered by `priority_score DESC, last_call_date ASC`, `LIMIT 5000`.\n\nThe view returns the HubSpot `contact_id` directly, so no database lookup is\nneeded.\n\n=== Priority (SQL)\n\nNever called goes straight to the top; otherwise a disposition weight is scaled by\nstaleness and damped by attempt count:\n\n[source,sql]\n----\nLEAST(4.0,\n  CASE\n    WHEN last_call_date IS NULL THEN 4.0\n    ELSE disposition_weight\n       * LEAST(1.3, 1.0 + days_since_last_call / 30.0)     -- staleness boost, capped\n       * (1.0 / (1.0 + 0.25 * total_calls))                -- attempt damping\n  END\n)\n----\n\nDisposition weights:\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Last disposition | Weight | Reading\n\n| Never called | 4.0 | Untouched — highest value\n| Left Live Message | 3.2 | Reached a human\n| Connected | 3.0 | Real conversation happened\n| No Answer | 2.6 | Neutral\n| Left Voicemail | 2.4 | Neutral\n| Busy | 2.2 | Neutral\n| Anything else | 1.6 | Default\n| Failed | 0.8 | Technical dead end\n| Wrong Number | 0.4 | Almost certainly unreachable\n|===\n\nThe three factors encode three intuitions: *a good last conversation is worth\nresuming*, *the longer it has been the more a call is due* (capped at +30%), and\n*each additional attempt reduces the value of one more* (four prior calls halve\nthe score).\n\n=== Suppression (Python)\n\nDropped when the contact was already called today in US Eastern, by any owner.\n\n=== Ownership\n\nThe row's `hubspot_owner_id` is respected when present. When it is blank, a\nround-robin rep is assigned and both `hubspot_owner_id` and `call_assigned_to` are\nwritten.\n\n[#no-show]\n== Unattended meeting (no-show)\n\nHandler: `lambdas/morpheus_hubspot/events/scheduled/unattended_meeting_reengagement.py`\nView: `bigquery/v_unattended_meeting_reeng.sql`\n\nThey booked a meeting, did not show up, and have since gone cold.\n\n=== Membership (SQL)\n\n* `bdr_rep` is set and non-blank on the deal\n* at least one associated contact vid\n* a MEETING engagement exists (`last_meeting_date IS NOT NULL`) — you cannot\n  no-show a meeting that never existed\n* the meeting outcome is null or not in `('COMPLETED')`\n* no contact activity of any type in the last 30 days\n* no call with an excluded disposition\n\nAll deal stages are included, open and closed. One row per contact, keeping the\nmost recent meeting. Ordered `priority_score DESC, last_meeting_date ASC`,\n`LIMIT 5000`.\n\n[WARNING]\n====\nThe view carries two `⚠` markers to confirm against production data: which\n`metadata.*` column holds the meeting outcome, and which value means \"attended\"\n(currently assumed to be `COMPLETED`). Both are isolated in the `last_meeting` CTE\nand the `WHERE` clause. If the assumption is wrong, this segment mis-selects.\n====\n\n=== Priority (SQL)\n\nBase 2.5, scaled by how recently they went cold and by how much history exists:\n\n[source,sql]\n----\nLEAST(4.0,\n  2.5\n  * recency_factor        -- NULL last activity → 1.2\n                          -- else GREATEST(0.5, 1.3 - (days_since_activity - 30) / 120.0)\n  * engagement_factor     -- 1.0 + 0.06 * LEAST(total_activities, 10)   → max 1.6\n)\n----\n\nRecency reference points: 30 days → 1.3, 90 days → 0.8, clamped to\n`[0.5, 1.3]`. Effective range is roughly 1.3 (cold and barely touched) to 4.0\n(recently cold and heavily engaged).\n\nThis is the inverse intuition to the supplier segment: here *just* gone cold is\nthe best time to call, because the relationship is still warm.\n\n=== Suppression (Python)\n\nDropped when already called today (US Eastern, any owner).\n\n=== Ownership\n\nRoute to the deal's BDR owner — but only when that owner is a real active sales\nrep. Blank, the AI fallback owner, or a non-active rep triggers round-robin\nreassignment.\n\n[#lost-deal]\n== Lost or stalled deal\n\nHandler: `lambdas/morpheus_hubspot/events/scheduled/reengagement/lost_deal_reengagement.py`\nView: `bigquery/v_lost_deal_reeng.sql`\n\n=== Membership (SQL)\n\nAll three must hold:\n\n. The contact's latest deal is *not* in an active pipeline stage — stage is null or\n  outside the active-stage list.\n. The deal was not closed-lost for a hard-disqualifying reason (not a seller, hard\n  underwriting decline, do-not-contact, and similar).\n. The contact has no call carrying an excluded disposition.\n\nDeals are deduplicated first. One row per contact: with multiple qualifying deals\nthe most recently modified wins. The query wrapper additionally requires a non-blank\n`phone`, orders by `priority_score DESC, last_call_date ASC`, and caps at\n`LIMIT 2000`.\n\n=== Priority (SQL)\n\nSame shape as the supplier segment — never called → 4.0, otherwise disposition\nweight × staleness boost (capped at 1.3) × attempt damping — with a slightly\ndifferent weight table (`3.2` for Wrong Number here rather than `0.4`, `0.8` for\nFailed, `1.6` default).\n\n[IMPORTANT]\n====\nThe handler *does not currently write* `call_priority` or `outreach_list_reason`\nfor this segment. Both lines are commented out, along with\n`_score_to_call_priority()`, so a lost-deal contact receives only\n`call_list_date` (and `call_assigned_to` / `hubspot_owner_id` when reassigned).\nThe view still computes and orders by `priority_score`; the handler ignores it.\n====\n\n=== Suppression (Python)\n\nDropped when already called today (US Eastern, any owner).\n\n=== Ownership\n\nRoute to the deal's owner when they are an active rep; otherwise round-robin.\n\n[#campaign]\n== Amazon seller campaign\n\nHandler: `lambdas/phone_agent/events/campaign_reengagement.py`\nView: `bigquery/v_amazon_seller_contact_engagement_summary.sql`\n\nThe one segment that runs in `phone-agent`, and the one that publishes an event\nrather than calling HubSpot.\n\n=== Membership (SQL)\n\nA `CampaignTarget` row where the last call engagement is null or older than one\nday. The view also joins the latest CALL engagement, the latest *non-completed*\nTASK engagement, and the latest outreach `Call` row, and exposes the HubSpot\nlifecycle stage and deal fields. Ordered\n`last_call_engagement_timestamp ASC NULLS FIRST` — never-called contacts first,\nthen the longest-neglected — `LIMIT BQ_REENGAGEMENT_BATCH_LIMIT` (5000).\n\n[NOTE]\n====\nThis previously ordered on `last_active_task_id ASC NULLS FIRST, task_created_date\nASC NULLS FIRST`. That is a *static* key: a contact's position never changed no\nmatter how often the segment ran, so whichever contacts sorted last were starved\nindefinitely. Ordering on last engagement is self-draining instead — once a contact\nis actually called, the timestamp advances and the row moves to the back.\n\nThe property only holds if reps work the list. Contacts enrolled but never dialled\nstay pinned at the front.\n====\n\nThe view computes a `priority` column (4 / 3 / 2 from outreach-call recency), but\nthe handler ignores it and computes its own.\n\n=== Priority (Python)\n\n[source,python]\n----\nis_ai_owner = row[\"engagement_owner_id\"] == AI_OWNER_ID     # \"86817215\"\nif not is_ai_owner:\n    return 1\nif last_engagement and (now - last_engagement) < timedelta(days=3):   # REENGAGEMENT_STALE_DAYS\n    return 4\nreturn 3\n----\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Condition | Priority | Reading\n\n| AI-owned last engagement within 3 days | 4 | The AI just spoke to them — strike while it is hot\n| AI-owned last engagement older than 3 days, or none | 3 | AI touched them at some point\n| Last engagement owner is not the AI (or unknown) | 1 | A human already owns this conversation\n|===\n\n=== Ownership\n\nSame three-way test as elsewhere, with one difference: the current owner is read\nfrom the *local* `contacts` row via the view's internal `contactid`, because the\nview returns a UUID rather than a HubSpot owner id. Owners for the whole batch are\nresolved in a single `get_many_by_ids()` query, alongside one query for the active\nrep pool.\n\nReps are then reserved in one `reserve_sales_rep_round_robin_batch()` call for\nexactly the number of rows needing reassignment. If that returns no rep, the handler\nlogs at `ERROR` and *aborts the whole batch* rather than continuing — the only\nsegment that does this. Because the reservation happens before any publish, the\nabort is now all-or-nothing rather than part-way through.\n\n=== Output\n\nPublishes a `Contact.Update` event per row to `CrmSyncQueue` via `publish_events()`,\nkeyed `MessageGroupId = hubspot_contact_id`, with the four properties (plus\n`hubspot_owner_id` when reassigned). Messages go out in `SendMessageBatch` calls of\nten; rejected entries are reported by group id rather than raised. The completion\nline reports `published=N/total reassigned=N failed=N`.\n\n[IMPORTANT]\n====\nThe whole handler is three phases — one batched read, in-memory planning, one\nbatched publish — so its cost is flat in the row count. It deliberately avoids\nper-row database and SQS round trips: at ~2500 rows those consumed the entire\n120-second Lambda timeout before reaching the end of the batch, and because\nEventBridge retries an async invocation twice, each retry re-processed the same head\nrows and never reached the tail.\n====\n\n== Shared preconditions\n\nEvery segment returns early, with a warning and no work, when:\n\n* `BIGQUERY_CREDENTIALS` is empty in the secret.\n* The BigQuery query raises — logged at `ERROR`, handler returns.\n* The view returns no rows.\n* `CRM_SYNC_QUEUE_URL` is empty (campaign segment only).\n\nPer-row failures — a failed `update_contact_properties()` or a failed publish — are\nlogged and counted; the batch continues. In the campaign segment, publish failures\nsurface as the group ids returned by `publish_events()`.\n\n== Cadence\n\nAll five rules are currently `rate(6 hours)` or `rate(3 hours)`, values set during\ndevelopment rather than the \"every 30 minutes\" their descriptions claim. See\nxref:infrastructure:schedules.adoc[EventBridge schedules] for the live values.\n\nBecause every segment recomputes membership and suppression from scratch on each\nrun, cadence affects freshness and cost but not correctness.\n\n== Comparison\n\n[cols=\"1,1,1,1,1\",options=\"header\"]\n|===\n| Segment | Priority source | Same-day guard | Identity | Owner override\n\n| Email click | View column 4/3/2 | Human call within 2 days | DB lookup by UUID | Existing owner kept\n| Supplier | `priority_score` → 1–4 | Called today | Direct from view | Existing owner kept\n| No-show | `priority_score` → 1–4 | Called today | Direct from view | Deal BDR owner\n| Lost deal | *not written* | Called today | Direct from view | Deal owner\n| Campaign | Python 4/3/1 | none (view: >1 day since call) | DB lookup by UUID | Existing owner kept\n|===\n\nThe sales-facing summary of the same rules is\nxref:call-lists.adoc[Daily call lists].\n"},{"component":"morpheus","module":"business","path":"business:call-lists.adoc","title":"Daily call lists","description":"How each sales rep's daily call list is assembled, prioritised, and materialised in HubSpot.","url":"https://docx.payability.com/morpheus/business/call-lists.html","navOrder":18,"source":"= Daily call lists\n:description: How each sales rep's daily call list is assembled, prioritised, and materialised in HubSpot.\n\nA contact appears on a rep's list for a day when three things are true at once:\n\n. *It is assigned to them* — `hubspot_owner_id` on the contact matches the rep.\n. *Today's date is stamped on it* — `call_list_date` equals today. This is what\n  makes a list \"today's\" and what makes it drop off tomorrow.\n. *It has a priority* — `call_priority` `1`–`4`, where *4 is most urgent*. Reps\n  work top-down.\n\nNobody adds contacts by hand. Automation watches for signals that a contact is\nworth calling and stamps those three properties.\n\n[TIP]\n====\nThis page describes the mechanism. For the rules deciding *which* signals qualify,\nsee xref:re-engagement.adoc[Re-engagement segments].\n====\n\n== The three ways a contact lands on a list\n\nWritten for the sales team; the same rules in engineering detail are in\nxref:re-engagement.adoc[Re-engagement segments].\n\n=== 1. They engaged on a phone call (Amazon seller follow-up)\n\nWhen the AI dialer reaches an Amazon seller, that contact is queued for a human\nfollow-up. Urgency depends on how recently the AI spoke to them:\n\n* AI talked to them in the *last 3 days* → *priority 4*\n* AI talked to them *longer ago*, or we are not sure → *priority 3*\n* The last contact was *not* from the AI → *priority 1*\n\nThese are round-robined across the team, so they go to whichever rep is next in\nline.\n\n=== 2. They clicked a link in one of our emails\n\nA contact who clicks a marketing email link and does not already have an open deal\nis added back to their *existing* owner's list. Freshness drives priority:\n\n* Clicked *today or yesterday* → *priority 4*\n* Clicked in the *last 2–6 days* → *priority 3*\n* Clicked *a week or more ago* → *priority 2*\n\nBecause they already have an owner, these stay with that person.\n\n=== 3. They created a Payability account but stalled\n\nA supplier who signed up — a deal in the \"appointment scheduled\" stage created\nrecently — but has not moved forward gets re-queued. Priority is a score blending\nseveral things:\n\n* *Never been called?* → top priority\n* *How the last call went* — connected, or left a live message, scores higher than\n  a dead end like a wrong number\n* *How long ago* the last call was — the longer it has been, the higher it nudges\n* *How many times* we have already called — more prior attempts pushes it down, so\n  we do not over-dial the same person\n\nThe score is rounded to a 1–4 priority. These also stay with their existing owner.\n\nTwo further segments feed the same lists: contacts who *booked a meeting and did\nnot attend* and have since gone quiet, and contacts on *lost or stalled deals* that\nwere not hard-disqualified.\n\n== A few things worth knowing\n\n* *Lists clear daily.* A contact you do not reach today may reappear tomorrow if it\n  still qualifies — or drop off if the situation changed, for example because they\n  booked a deal.\n* *We avoid double-dialing.* A person called recently is held back for a couple of\n  days before being re-added.\n* *Open deals are excluded.* Once a contact has an active deal, they stop appearing\n  on re-engagement lists.\n* *Priority is your running order.* Start at 4 and work down. A 4 means something\n  happened recently that makes a call timely.\n\nQuestions about why a specific contact showed up, or did not? The\n`outreach_list_reason` property on the contact records which automation enrolled\nthem, and the segment rules are in\nxref:re-engagement.adoc[Re-engagement segments].\n\n[#suppression]\n== Suppression rules\n\nSuppression always beats enrolment. A contact is held back when any of these\napply:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Rule | Where enforced | Applies to\n\n| Has an open or existing deal\n| BigQuery view `WHERE` clause\n| Email click (`property_deal_id IS NULL`); supplier and no-show restrict by deal stage instead\n\n| Ever had a call with a blocked disposition — wrong number, do not call, not qualified, not a seller\n| BigQuery `blocked_emails` / `bad_disposition_calls` CTE\n| Email click, no-show, lost deal, supplier\n\n| Closed-lost for a hard-disqualifying reason\n| BigQuery view `WHERE` clause\n| Lost deal\n\n| Unsubscribed\n| `unsubscribedat IS NULL`\n| Email click\n\n| Already called *today* (US Eastern, any owner)\n| Python, in the handler\n| Supplier, no-show, lost deal\n\n| Called by a *human* owner in the last 2 days\n| Python, in the handler\n| Email click\n\n| Any contact activity in the last 30 days\n| BigQuery view `WHERE` clause\n| No-show — this segment specifically wants cold contacts\n\n| No resolvable HubSpot contact id\n| Python (counted as `skipped_no_hs_id`) or an `INNER JOIN` in SQL\n| All segments\n|===\n\nAn AI-owned call never suppresses a human follow-up. That distinction — comparing\nthe engagement owner against the AI fallback owner id `86817215` — is what lets a\ncontact the AI just spoke to become a *priority 4* human call rather than being\ntreated as \"recently contacted\".\n\n[#materialisation]\n== List materialisation\n\nStamping properties is only half the job; the rep needs a list to open. That is\n`Hubspot.SalesRepCallListRefresh`\n(`lambdas/morpheus_hubspot/events/scheduled/sales_rep_call_list_refresh.py`).\n\nFor each active sales rep with a HubSpot owner id:\n\n. *Delete* the rep's existing call-segment list, if\n  `sales_reps.hubspotCallSegmentId` is set.\n. *Create* a fresh dynamic HubSpot Contacts list filtered to\n  `hubspot_owner_id = <rep>` AND `call_list_date = <today>`.\n. *Write back* the new list id to `sales_reps.hubspotCallSegmentId`.\n\nThe list is named from `CALL_LIST_NAME_TEMPLATE`:\n`Outreach-SmartList-{rep_name}-{MM-DD-YYYY}`. The HubSpot object type id for\nContacts is `0-1`.\n\nEach rep is processed best-effort — one rep's failure is logged and does not stop\nthe batch. Rep fields are snapshotted out of the session before the HubSpot calls,\nso a database connection is not held open across CRM latency.\n\n[IMPORTANT]\n====\nDeleting and recreating the list means the list *id* changes daily. Anything\nholding a saved list id — a Five9 or Nooks import, a saved view, a bookmark —\nmust read `sales_reps.hubspotCallSegmentId` rather than caching the id.\n====\n\nThe rule is `ScheduleV2` with `ScheduleExpressionTimezone: America/New_York`,\nintended for 6 AM ET daily. The schedule expression is currently\n`rate(6 hours)`; the `cron(0 6 * * ? *)` line is commented out. See\nxref:infrastructure:schedules.adoc[EventBridge schedules].\n\n== \"Today\" in US Eastern\n\nHandlers derive today's date by adding a fixed `-4` hour offset to UTC and taking\nthe date, rather than using a timezone database:\n\n[source,python]\n----\n_US_EASTERN_UTC_OFFSET = timedelta(hours=-4)\ntoday_et = (datetime.now(timezone.utc) + _US_EASTERN_UTC_OFFSET).strftime(\"%Y-%m-%d\")\n----\n\nThis is exact during EDT. During EST (UTC−5) it is off by one hour, so a handler\nrunning between 04:00 and 05:00 UTC can compute tomorrow's date. The list refresh\nruns at 6 AM ET, an hour at which both offsets agree on the calendar date, so the\nmaterialised list is unaffected; the re-engagement handlers run throughout the day\nand are the ones exposed to the edge.\n\nThe BigQuery views do this properly, using\n`CURRENT_DATE('America/New_York')` and `DATE(ts, 'America/New_York')`.\n\n== Ordering within a priority\n\n`call_priority` is the only ordering signal the rep sees. Within a priority band\nthe segments order their *own* candidate rows before the cap\n(`ORDER BY priority_score DESC, last_call_date ASC`, and `LIMIT 5000` or `2000`), so\nordering only matters for deciding which rows get enrolled at all when a segment\nexceeds its cap.\n\n== Diagnosing a missing contact\n\n. Read `outreach_list_reason` and `call_list_date` on the HubSpot contact. Stale\n  date means no segment enrolled them today.\n. Check whether they now have a deal — that excludes most segments.\n. Check for a blocked disposition on any of their calls.\n. Check whether they were called today, or in the last 2 days by a human.\n. Confirm the contact has a `hubspotContactId` in the local database — the email\n  click and campaign segments resolve identity through it and drop rows without it.\n. Confirm their owner is an *active* `sales_reps` row; a contact owned by an\n  inactive rep is reassigned, so it may be on someone else's list.\n. Confirm the contact has a `phone` value. The list filter requires\n  `phone IS_KNOWN`, and no segment checks it before enrolling — a contact with only\n  `mobilephone` gets `call_list_date` written and still appears on no list.\n. Check the handler's completion line in CloudWatch:\n  `… completed updated=N assigned=N skipped_no_hs_id=N total=N`.\n. If that completion line is *absent*, the run was killed by the Lambda timeout\n  part-way through. Look for `Task timed out` in the function's log group. A\n  truncated run always drops the same rows — whichever ones the segment's\n  `ORDER BY` puts last — so a contact can be missing every day while the job\n  reports no errors.\n"},{"component":"morpheus","module":"business","path":"business:follow-up-tasks.adoc","title":"Follow-up tasks and escalation","description":"Task creation, HubSpot mirroring, status polling, and the three-stage Slack escalation ladder.","url":"https://docx.payability.com/morpheus/business/follow-up-tasks.html","navOrder":19,"source":"= Follow-up tasks and escalation\n:description: Task creation, HubSpot mirroring, status polling, and the three-stage Slack escalation ladder.\n\nA follow-up task is a commitment that a specific rep will do a specific thing by a\nspecific time. Morpheus creates them, mirrors them into HubSpot, watches whether\nthey get done, and escalates when they do not.\n\nTable: `follow_up_tasks`. Implementation:\n`lambdas/morpheus_hubspot/events/tasks/crm_task_create.py`,\n`events/follow_up_task_sync.py`,\n`events/scheduled/follow_up_task_hubspot_poll.py`,\n`lambdas/phone_agent/events/task_deadline_reminder.py`.\n\n== The record\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Column | Meaning\n\n| `salesRepId` | Who owns the task\n| `contactId` | Which lead it is about\n| `campaignTargetId`, `contactSequenceId` | Optional links to what triggered it\n| `type` | `call`, `email`, or `sequence_enrollment`\n| `status` | `pending`, `in_progress`, `completed`, `skipped`, `failed`\n| `hubspotTaskId` | HubSpot Task object id\n| `nooksTaskId`, `nooksSequenceStateId` | Nooks task / sequence enrolment ids\n| `hubspotSyncStatus`, `nooksSyncStatus` | `pending`, `synced`, `failed` per platform\n| `dueAt` | Deadline\n| `escalationStage` | `0` → `1` → `2` → `3`; 3 means abandoned\n| `lastEscalatedAt` | When the last escalation fired\n| `completedAt` | Set when the task completes\n| `lastHubspotSyncAt`, `lastNooksSyncAt` | Poll deprioritisation stamps\n|===\n\nBoth external ids are nullable — a task may be pushed to only one platform\ndepending on which integrations are active.\n\n== Creation — `Crm.TaskCreate`\n\nHubSpot first, local mirror second, because the local row needs the HubSpot id.\n\n[mermaid]\n....\nsequenceDiagram\n  participant Q as CrmSyncQueue\n  participant H as CrmTaskCreateHandler\n  participant HS as HubSpot\n  participant DB as PostgreSQL\n\n  Q->>H: Crm.TaskCreate\n  H->>DB: resolve sales_reps row by hubspot_owner_id\n  H->>DB: resolve contact (contact_id or hubspot_contact_id)\n  H->>HS: create_crm_task() → HubSpot task id\n  H->>DB: insert follow_up_tasks row with hubspotTaskId\n....\n\nRequirements enforced by the event model and the handler:\n\n* At least one of `contact_id` (internal UUID) or `hubspot_contact_id` — a\n  `model_validator` rejects a payload with neither.\n* `hubspot_owner_id` is mandatory. It assigns the HubSpot Task *and* selects the\n  `sales_reps` row for the local mirror.\n* `subject` is required and becomes `hs_task_subject`.\n* `task_type` maps `call`/`email`/`todo` → HubSpot `CALL`/`EMAIL`/`TODO`, and\n  `call`/`email` → the local `FollowUpTaskType` enum.\n* `due_at` becomes `hs_timestamp`; `priority` defaults to `MEDIUM`; `body` becomes\n  `hs_task_body`.\n* `hubspot_company_id` and `campaign_target_id` are optional associations.\n\nThere is a second creation path: `Crm.NewActivity` creates a follow-up Task\nautomatically when it logs an *incoming* email (`hs_email_direction =\nINCOMING_EMAIL`) and the event carries a `hubspot_owner_id`. See\nxref:crm-sync.adoc#activities[activities].\n\n== Status sync — `FollowUpTask.Sync`\n\nPublished when a task's status changes. The handler *reads HubSpot first*, which\nmatters: a rep who completed the task in HubSpot should not have that overwritten\nby a stale local status.\n\n* If HubSpot already reports `COMPLETED` → mark the local row completed, and make\n  no HubSpot write.\n* Otherwise → PATCH the HubSpot task with the mapped status and update the local\n  row.\n\nStatus mapping:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| `FollowUpTaskStatus` | HubSpot `hs_task_status`\n\n| `pending` | `NOT_STARTED`\n| `in_progress` | `IN_PROGRESS`\n| `completed` | `COMPLETED`\n| `skipped` | `DEFERRED`\n| `failed` | `DEFERRED`\n|===\n\nHubSpot has no \"failed\" concept, so both terminal non-success states land on\n`DEFERRED`.\n\n== Completion polling — `Hubspot.FollowUpTaskPoll`\n\nEvery 30 minutes, `morpheus-hubspot` pulls pending tasks that have a\n`hubspotTaskId` (`list_pending_with_hubspot_task()`, capped by\n`HUBSPOT_TASK_POLL_BATCH_SIZE`, default 100) and reads each one from HubSpot:\n\n* `hs_task_status = COMPLETED` → mark the local row completed and stamp\n  `completedAt`.\n* Anything else → stamp `lastHubspotSyncAt` only, which deprioritises the task in\n  the next window so the batch keeps advancing through unchecked tasks.\n* `NotFoundException` — the task was deleted in HubSpot — is handled rather than\n  treated as an error.\n\nThis is how work done *in HubSpot* becomes visible to Morpheus. Without it, a rep\ncompleting a task in the CRM would still be escalated.\n\n[#escalation]\n== The escalation ladder — `Phone.TaskDeadlineReminder`\n\nRuns in `phone-agent` at 08:00, 14:00, and 20:00 UTC and does two things in one\ninvocation. Both phases return early with a warning when `SLACK_BOT_TOKEN` or\n`SALES_SLACK_CHANNEL_ID` is unset.\n\n=== Phase 1 — pre-deadline reminders\n\n`list_pending_due_soon(window_hours=24)` selects pending tasks due within 24\nhours. They are grouped by rep (`group_by_rep`), rendered into Slack Block Kit\n(`build_reminder_blocks`), and posted as a single channel message:\n`\"N follow-up tasks due in the next 24 hours\"`.\n\nAn `Activity` row of type `reminder` is then inserted per task — subject\n`\"Slack deadline reminder sent\"`, notes `slack_reminder_sent` — as a best-effort\naudit trail. A failure there is logged and ignored.\n\n=== Phase 2 — escalation\n\n`list_stale_for_escalation()` selects tasks past their deadline by a day or more.\nEach is escalated exactly *one stage per run*, with a 3-second sleep between tasks\nto stay inside Slack rate limits. A failure on one task is logged and the loop\ncontinues.\n\n[cols=\"1,3,1\",options=\"header\"]\n|===\n| Stage | Action | New stage\n\n| 0\n| Public channel message — `:warning: *Overdue Task*` — mentioning the rep (Slack lookup by email, falling back to their name), with a 100-character note preview and a HubSpot contact deep link when available.\n| 1\n\n| 1\n| Direct message to the rep — `:rotating_light: *Urgent — Overdue Task*` — after resolving their Slack user id from their email. Plus an `Email.SendInternal` message published to `EmailAgentQueue` carrying the task id, rep name and email, contact id, due date, and notes.\n| 2\n\n| 2\n| No new notification. The task is marked `status = failed`.\n| 3\n\n| 3\n| Terminal. The handler returns without action.\n|===\n\nAt stage 3, if the task has a `hubspotTaskId`, a `FollowUpTaskSyncEvent` with\nstatus `failed` is published to `CrmSyncQueue` so HubSpot reflects the abandonment\n(mapping to `DEFERRED`).\n\nEvery escalation writes `escalationStage` and `lastEscalatedAt` in one update, so\nthe ladder cannot advance twice in one run.\n\n[NOTE]\n====\n`Email.SendInternal` is published to `EmailAgentQueue` but is *not registered* in\n`email_agent/registry.py`. The dispatcher logs \"No handler registered for SQS\nevent_type\" and deletes the message. Stage-1 escalation therefore currently\nnotifies by Slack DM only; the email path is scaffolding awaiting a handler.\n====\n\nIf `EMAIL_AGENT_QUEUE_URL` is unset, the publish is skipped with a warning and the\nstage still advances.\n\n== Slack integration\n\n`lambdas/phone_agent/services/slack_client.py` wraps the Slack Web API:\n\n* `send_message(channel, text, blocks)` — channel post\n* `send_dm(user_id, text, blocks)` — direct message\n* `resolve_user_id(email)` — email → Slack user id, needed for DMs\n* `mention(email, name)` — a real `<@Uxxxx>` mention when the lookup succeeds, the\n  plain name otherwise\n\nBlock builders live in `utils/task_blocks.py`. HubSpot deep links are constructed\nas `https://app.hubspot.com/contacts/{hubspot-portal}/record/0-1/<contactId>`.\n\n== Ownership\n\nTasks inherit their rep from whoever created them. When no rep is specified, the\nround-robin selector assigns one — see\nxref:ownership.adoc[Ownership and round-robin]. The `require_nooks` flag on that\nselector exists for `sequence_enrollment` tasks, which need a `nooksUserId`.\n"},{"component":"morpheus","module":"business","path":"business:email-engagement.adoc","title":"Email engagement","description":"Reply categorisation, engagement events pushed to HubSpot, and how opt-outs propagate.","url":"https://docx.payability.com/morpheus/business/email-engagement.html","navOrder":20,"source":"= Email engagement\n:description: Reply categorisation, engagement events pushed to HubSpot, and how opt-outs propagate.\n\nEmail campaigns are sent by the Payability web application. Morpheus handles what\ncomes *back*: classifying replies, recording engagement in the CRM, and honouring\nopt-outs.\n\nImplementation: `lambdas/email_agent/`,\n`lambdas/morpheus_hubspot/events/crm_custom_event.py`,\n`events/webhook_processors/newsletter_opt_out.py`,\n`events/contact_unsubscribe.py`.\n\n== Reply categorisation — `Email.IncomingCategorise`\n\nAn inbound reply is classified into one of seven categories and the result is\nwritten onto the campaign target row, so the campaign can branch and reporting can\ncount outcomes.\n\n=== Categories\n\n`EmailTargetCategory`:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Category | Meaning\n\n| `interested` | Wants to learn more, meeting intent, clear positive signal\n| `not_interested` | Declines, not a fit, stop contacting\n| `question` | Asks for information without clear meeting intent yet\n| `out_of_office` | Automated away or vacation reply\n| `unsubscribe` | \"Remove me\", \"stop emails\", spam-complaint tone\n| `wrong_person` | Wrong recipient, or not the relevant role\n| `other` | None of the above\n|===\n\n=== Agent\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Model | `AgentModelIds.NOVA_LITE` — the classification is a small enum plus short reasoning, so a cheap model suffices\n| `max_tokens` | 1024\n| `max_turns` | 3\n| Tools | none — `tools=lambda: []`\n| Structured output | `EmailReplyCategorisationResult` (`category`, optional `reasoning`)\n|===\n\n[NOTE]\n====\nNova is not an Anthropic model, so `ClaudeAgentService` disables Bedrock prompt and\ntool cache points for it. `cachePoint` in `toolConfig` is Anthropic-only and Nova\nrejects it with a `ValidationException`. See\nxref:libs:morpheus-llm.adoc#caching[prompt caching].\n====\n\n=== Pipeline\n\n[mermaid]\n....\nflowchart TD\n  A[\"Email.IncomingCategorise\\n(SQS body)\"] --> B[\"parse campaign_id\"]\n  B --> C[\"session 1:\\nresolve EmailCampaignTarget\\n+ tenant checks\\n+ build outbound context\"]\n  C --> D[\"Bedrock call\\noutside any DB session\"]\n  D --> E[\"session 2:\\nre-fetch target, re-check tenant,\\nwrite category fields\"]\n  E --> F[\"optional: update emails row\"]\n....\n\nTwo separate sessions, deliberately: the LLM call runs *outside* any database\nsession so a connection is not held open across Bedrock latency. The target row is\nre-fetched afterwards and the tenant re-checked, because the row could have changed\nduring the call.\n\n=== Target resolution\n\nMutually exclusive on the event model:\n\n* `email_campaign_target_id` + `campaign_id` — load by primary key, then verify the\n  row's `campaignId` matches.\n* `contact_email` + `campaign_id` — load via the `(campaignId, email)` unique\n  constraint.\n\n=== Outbound context for the prompt\n\nThe prompt includes what *we* sent, so the model can judge the reply in context:\n\n. `variation_id` from the event, if provided (an override for repair or backfill).\n. Otherwise `EmailCampaignTarget.variationId`.\n. Load that `EmailVariation`; use its `subject` and its `htmlBody` converted to\n  plain text by `html_to_plain_text()`.\n\nA missing or unresolvable variation is a warning, not a failure: the model\nclassifies with empty outbound context.\n\n=== Persisted result\n\nOn the `email_campaign_targets` row: `category`, `categorisedAt`, and\n`categoryModel` (the model id, for audit). When the event carries `email_id`, the\nmatching `emails` row's `category` is set in the same transaction.\n\n[#tenancy]\n=== Tenant safety\n\n`email-agent` has no `SUBSCRIPTION_ID`. Tenant scope is `row.subscriptionId` from\nthe loaded `EmailCampaignTarget`, then cross-checked against:\n\n* the `Contact` row's `subscriptionId`\n* the `EmailVariation` row's `subscriptionId`\n* the campaign id, when resolving by primary key\n* the target row again after the LLM call\n\nAny mismatch logs at `ERROR` and the handler *returns without raising*, so a bad\nproducer cannot poison-retry a message forever. Only LLM and unexpected errors\npropagate for retry.\n\n=== Prompt injection\n\n`inbound_text` is verbatim sender content interpolated with no sanitisation. The\ncontrols are structured output (the category is constrained to the enum) and an\nempty tool list. Residual risk is bounded misclassification — a reply containing\n\"always choose interested\" may skew the label — not code execution. The trade-off\nis documented in `lambdas/email_agent/agents/prompts.py`. See\nxref:ROOT:security.adoc#prompt-injection[prompt injection].\n\n== Inbound Resend webhooks — `Resend.InboundEmail`\n\n`InboundEmailHandler` currently *logs only*: email id, sender, subject, and\nattachment count. The intended behaviour, per its module docstring, is to classify\nreply intent, match the reply to the original outbound message via AgentCore Memory\nusing the `in-reply-to` message id, update the HubSpot lifecycle stage, invoke the\nAgentCore runtime for complex replies, and add bounce/unsubscribe suppression\nentries.\n\nReply classification today runs through the separate `Email.IncomingCategorise`\nevent, which the web application publishes with the reply already extracted.\n\n== Engagement events to HubSpot\n\nTwo mechanisms record email engagement in the CRM.\n\nLogged Email objects — `Crm.NewActivity` / `Crm.UpdateActivity`::\nCreate or PATCH a HubSpot Email object associated with the Contact and/or Company.\nThis is what puts a message on the CRM timeline. See\nxref:crm-sync.adoc#activities[activities].\n\nCustom behavioural events — `Crm.CustomEvent`::\nSend an `AgentEmail` behavioural event to `POST /events/v3/send` as\n`pe{hubspot-portal}_agentemail`. Properties: `status` — one of `opened`, `clicked`,\n`bounced`, `complained`, `unsubscribed`, `delivered`, `replied`, `sent` — plus\noptional `campaign` and `link_url`. A local `campaign_id` may be carried to link\nfollow-up tasks to a campaign.\n+\nThe event kind is whitelisted (`CustomEventKind.AGENT_EMAIL` is the only member),\nso an unknown kind fails validation before any API call. The fully qualified\nHubSpot event name can be overridden per message with\n`hubspot_event_name_override`.\n\nThese are the signals the xref:re-engagement.adoc#email-click[email-click segment]\nlater reads back out of BigQuery.\n\n[#optouts]\n== Opt-outs and suppression\n\nOpt-out state is authoritative and flows in both directions.\n\nMorpheus → HubSpot — `Contact.Unsubscribe`::\nWrites HubSpot Communication Preferences via\n`+POST /communication-preferences/…/statuses/{email}+`. Each\n`SubscriptionPreferenceUpdate` names a numeric subscription type id and a status of\n`SUBSCRIBED`, `UNSUBSCRIBED`, or `NOT_SPECIFIED`. The subscriber email comes from\n`contact_email` when set, otherwise from a CRM `get_by_id` on\n`hubspot_contact_id`; the event model requires at least one of the two.\n`legal_basis` and `legal_basis_explanation` are passed through when portal settings\nrequire them.\n\nHubSpot → Morpheus — `Crm.HubSpotWebhook`::\n`NewsletterOptOutProcessor` matches `contact.propertyChange` notifications where\nthe property name is `hs_email_optout_<subscriptionTypeId>` and the id equals\n`HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID` (`44467216`). It reads the contact's\nemail from the CRM, then sets `newsletterOptOut` and `newsletterOptOutAt` on the\nmatching `target_emails` row — newsletter only, not the other two opt-out flags.\n+\nThree robustness details: a missing or blank `propertyValue` is treated as\n`false` (resubscribed); an unrecognised value logs a warning and returns; and\n`newsletterOptOutAt` is not moved backwards, so an out-of-order webhook delivery\ncannot undo a newer state. A 404 from the CRM, a contact with no email, or no\nmatching `target_emails` row each log and return. The processor does not commit —\nthe handler's `get_session()` block commits once per message.\n\nBigQuery contact sync::\n`Scraper.BigQueryContactSync` propagates `property_hs_email_optout` from the\nHubSpot export to all three `target_emails` opt-out flags when creating a row, and\nupdates them when the value changed.\n\n=== Suppression columns on `target_emails`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Column | Meaning\n\n| `newsletterOptOut`, `newsletterOptOutAt` | Opted out of newsletter sends\n| `oneToOneOptOut`, `oneToOneOptOutAt` | Opted out of one-to-one sends\n| `marketingOptOut`, `marketingOptOutAt` | Opted out of marketing sends\n| `isBlocked`, `isDeleted`, `isActive` | Hard suppression flags\n| `newsletterGapDays` (default 7), `newsletterLastSentAt` | Minimum spacing between newsletter sends\n|===\n\nAn index on `(subscriptionId, isDeleted, isBlocked, newsletterOptOut)` exists\nspecifically to make sendable-address selection cheap.\n\nThe email-click re-engagement segment additionally requires\n`unsubscribedat IS NULL` on the campaign target, so an unsubscribe removes a\ncontact from call-list enrolment as well as from sending.\n\n== Campaign data model\n\nOwned by the web application; Morpheus reads and annotates it.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Table | Role\n\n| `Campaign` (`type = email`) | Campaign configuration: sending rates, windows, Resend topic\n| `EmailVariation` | Content variants — subject and HTML body, spam score, usage count\n| `EmailCampaignTarget` | One row per (campaign, contact): status, `sentAt`/`openedAt`/`clickedAt`/`unsubscribedAt`, `resendEmailId`, `variationId`, `category`\n| `TargetEmails` | Address registry and suppression state\n| `Emails` | Individual messages, inbound and outbound, threaded by `threadId`\n| `Domain`, `EmailSendingDomain` | Sending domains, daily caps, warm-up status\n|===\n\n`EmailTargetStatus` progresses `pending` → `requested` → `sent` → `opened`, with\nterminal `bounced`, `unsubscribed`, `blocked`, `failed`.\n\nNote the two distinct category enums: `EmailTargetCategory` is the LLM reply\nclassification (this page); `EmailInboxCategory` — `campaign`, `reply`,\n`transactional`, `other` — is the inbox filter taxonomy on `Emails`. See\nxref:data:prisma-schema.adoc[Prisma schema reference].\n"},{"component":"morpheus","module":"business","path":"business:phone-intelligence.adoc","title":"Phone line intelligence","description":"Twilio line-type classification, risk tiers, automatic blocking, and the CRM write-back.","url":"https://docx.payability.com/morpheus/business/phone-intelligence.html","navOrder":21,"source":"= Phone line intelligence\n:description: Twilio line-type classification, risk tiers, automatic blocking, and the CRM write-back.\n\nBefore a number is dialled it is worth knowing what kind of line it is. A mobile\nanswers; a premium-rate line costs money; a voicemail-only number wastes an\nattempt. `Phone.LineIntelPending` classifies every new number and blocks the ones\nnot worth calling.\n\nImplementation: `lambdas/phone_agent/events/line_intel_pending.py`,\n`services/twilio_lookup.py`.\n\n== Trigger and batching\n\nThe `PhoneLineIntelTwiceDaily` schedule fires `Phone.LineIntelPending`. Each run\nselects rows from `target_phone_numbers` where `syncTwilio` is false\n(`list_pending_twilio_sync()`), capped at `PHONE_AGENT_BATCH_SIZE`.\n\n[IMPORTANT]\n====\n`PHONE_AGENT_BATCH_SIZE` is `25` in `lambdas/template.yaml`, but the `Settings`\ndefault in `models/config.py` is `1`. The template value applies in production; a\nlocal run without the variable set processes one number per invocation.\n====\n\nThe handler returns immediately with an `ERROR` when `CRM_SYNC_QUEUE_URL` is not\nconfigured — without it the CRM half of the job cannot happen.\n\n== Twilio Lookup v2\n\n`fetch_line_intelligence()` issues a single `GET` against\n`+https://lookups.twilio.com/v2/PhoneNumbers/{e164}?Fields=line_type_intelligence,line_status+`\nusing Basic auth built from `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN`, with a\n30-second timeout. HTTP and network errors are logged and re-raised, and the\nper-number `try`/`except` in the handler moves on to the next row.\n\nParsed into `TwilioLineIntelResult`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Twilio field | Stored as\n\n| `calling_country_code` | `countryCode`\n| `line_type_intelligence.carrier_name` | `carrierName`\n| `line_type_intelligence.mobile_country_code` | `mobileCountryCode`\n| `line_type_intelligence.mobile_network_code` | `mobileNetworkCode`\n| `line_type_intelligence.type` | `lineType` (mapped to the `LineTypes` enum)\n| derived | `riskLevel`, `isBlocked`\n|===\n\n== Line type to risk tier\n\n`LineTypes` mirrors Twilio's vocabulary exactly, which is why the enum values are\ncamelCase (`fixedVoip`, `nonFixedVoip`, `tollFree`, `sharedCost`, `uan`) rather\nthan snake_case. An unrecognised type maps to `None`.\n\n[cols=\"2,1,1,3\",options=\"header\"]\n|===\n| Twilio type | Risk | Blocked | Reading\n\n| `mobile` | low | no | Best case\n| `landline` | low | no | Fine\n| `fixedVoip` | medium | no | Business VoIP, usually legitimate\n| `tollFree` | medium | no |\n| `sharedCost` | medium | no |\n| `voip` | medium | no |\n| `uan` | medium | no | Universal access number\n| `unknown` | medium | no | Default for anything unmapped\n| `other` | medium | no |\n| `nonFixedVoip` | high | *yes* | Disposable / burner VoIP\n| `premium` | critical | *yes* | Premium-rate; dialling costs money\n| `voicemail` | critical | *yes* | Voicemail only; no one to reach\n| `pager` | critical | *yes* | Not a voice line\n|===\n\nThe blocking rule is one line: risk strictly above `medium`.\n\n[source,python]\n----\nblocked = rl in (RiskLevel.HIGH, RiskLevel.CRITICAL)\n----\n\n`isBlocked = True` on a `target_phone_numbers` row is what keeps the number out of\nfuture dialling selections.\n\n== Write-back\n\nPer number, in order:\n\n. Update the `target_phone_numbers` row with all six Twilio fields plus\n  `isBlocked` and `syncTwilio = True`. `syncTwilio` is the idempotence marker — the\n  row is never looked up again. A row that vanished between selection and update\n  logs a warning and is skipped.\n. Build HubSpot properties from whatever Twilio returned:\n+\n[cols=\"2,2\",options=\"header\"]\n|===\n| HubSpot contact property | Value\n\n| `phone_carrier_name` | `carrier_name`\n| `phone_line_type` | The raw Twilio type string\n|===\n+\nBoth are custom properties the portal must define. Neither present means nothing\nto sync — logged as a warning, row skipped.\n. Resolve a HubSpot contact id. The `target_phone_numbers` row must have a\n  `contactId`, and that `contacts` row must have a `hubspotContactId`. Anything\n  missing logs a warning naming the fix (\"link contact with hubspotContactId or\n  attach contactId\") and the row is skipped — *after* the database write, so the\n  Twilio call is not repeated on the next run.\n. Publish `Contact.Update` to `CrmSyncQueue` with\n  `MessageGroupId = target_phone_number_id` and\n  `target_phone_number_id` set on the event, so `morpheus-hubspot` stamps the row\n  after a successful PATCH.\n\n[mermaid]\n....\nsequenceDiagram\n  participant EB as EventBridge\n  participant PA as phone-agent\n  participant TW as Twilio Lookup v2\n  participant DB as PostgreSQL\n  participant Q as CrmSyncQueue\n  participant HF as morpheus-hubspot\n  participant HS as HubSpot\n\n  EB->>PA: Phone.LineIntelPending\n  PA->>DB: list rows where syncTwilio = false (limit BATCH_SIZE)\n  loop each number\n    PA->>TW: GET line_type_intelligence\n    PA->>DB: update row + syncTwilio = true\n    PA->>DB: resolve contact → hubspotContactId\n    PA->>Q: Contact.Update (carrier, line type)\n  end\n  Q->>HF: dispatch\n  HF->>HS: PATCH contact properties\n  HF->>DB: stamp target_phone_numbers row\n....\n\n== Where numbers come from\n\n`target_phone_numbers` rows are created by:\n\n* `Scraper.BigQueryContactSync` — from `property_phone` and\n  `property_mobilephone` on the HubSpot contact export, normalised and deduplicated\n  by number.\n* `Hubspot.Five9CallSync` — creating a callee row for a number seen in a Five9 call\n  log.\n* The web application, on import or manual entry.\n\nEach new row starts with `syncTwilio = false`, so it is picked up on the next\n`Phone.LineIntelPending` run.\n\n== Number normalisation\n\n`morpheus_hubspot/utils/phone.py` provides the shared normaliser, used by the Five9\nsync and contact resolution:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Input | Result\n\n| Already E.164 (`+1302…`) | Non-digits stripped, `+` restored\n| Bare 10-digit US number | `+1` prefixed\n| 11+ digits without `+` | `+` prefixed\n| Leading `011` or `001` international access prefix (without `+`) | Prefix stripped, `+` prefixed\n| Fewer than 7 digits, or empty | `None`\n|===\n\n`phone_digits()` strips the leading `+` for `LIKE` searches, so a query on\n`13023145526` matches both `+13023145526` and `13023145526` as stored.\n\n== Cost control\n\n`ReservedConcurrentExecutions: 2` on the function, and a batch cap per run. Twilio\nLookup is billed per request, and `syncTwilio` guarantees each number is looked up\nonce. Re-running a number requires clearing the flag:\n\n[source,sql]\n----\nUPDATE target_phone_numbers SET \"syncTwilio\" = false\nWHERE \"phoneNumber\" = '+13025550100';\n----\n"},{"component":"morpheus","module":"business","path":"business:call-logging.adoc","title":"Call logging and dispositions","description":"How AI dialer calls and Five9 rep calls are logged to HubSpot, and how dispositions drive decisions.","url":"https://docx.payability.com/morpheus/business/call-logging.html","navOrder":22,"source":"= Call logging and dispositions\n:description: How AI dialer calls and Five9 rep calls are logged to HubSpot, and how dispositions drive decisions.\n\nEvery call — AI or human — has to end up in two places: a `Call` row in the\noperational database and a Call engagement on the HubSpot contact timeline. Two\nseparate paths do this, because the two dialers are completely different systems.\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Path | Trigger | Source\n\n| AI dialer | `Crm.DialerCallEnded` (SQS, event-driven) | The web application's Retell-based dialer\n| Human dialer | `Hubspot.Five9CallSync` (scheduled) | Five9 CSV exports dropped on Files.com\n|===\n\nImplementation: `lambdas/morpheus_hubspot/services/dialer_call_sync.py` (688 lines,\nthe single densest piece of business logic in the platform) and\n`events/scheduled/five9_call_sync.py`.\n\n== AI dialer sync — `Crm.DialerCallEnded`\n\nThe event is deliberately thin — `call_id`, `target_id`, `campaign_id`,\n`subscription_id`, plus a few descriptive fields. Everything the sync needs is read\nfrom the `Call` and `CampaignTarget` rows, so the message cannot go stale.\n\n[mermaid]\n....\nflowchart TD\n  A[\"load Call + CampaignTarget\"] --> B[\"effective disposition\"]\n  B --> C{\"machine / screening?\"}\n  C -->|yes| C1[\"hubspotSyncStatus = skipped\\nreturn\"]\n  C -->|no| D[\"pick owner\\nround-robin, else fallback owner\"]\n  D --> E[\"build contact properties\"]\n  E --> F[\"resolve HubSpot contact\\ntarget id, else phone-first search\"]\n  F -->|none| F1[\"hubspotSyncStatus = skipped\\nreturn\"]\n  F --> G[\"backfill local Contact row\"]\n  G --> H{\"disposition = Amazon_Seller?\"}\n  H -->|yes| H1[\"promote lifecycle stage to MQL\\nstamp call list, priority 4\"]\n  H --> I[\"create HubSpot Call engagement\\nrich HTML summary + outcome GUID\"]\n  I -->|failure| I1[\"hubspotSyncStatus = failed\\nreturn\"]\n  I --> J[\"mark Call + Target synced\"]\n  J --> K{\"follow-up trigger?\"}\n  K -->|no| K1[\"done\"]\n  K -->|yes| L[\"create HubSpot Task\\n+ Activity row\\n+ contact heatStatus = hot\"]\n  L --> M[\"optional Nooks sequence enrolment\"]\n....\n\n=== Effective disposition\n\nThe stored disposition is overridden by two stronger machine signals:\n\n[source,python]\n----\nif call.callScreeningDetected: return Disposition.AI_SCREENING\nif call.inVoicemail:           return Disposition.VOICEMAIL_DETECTED\nreturn call.disposition or Disposition.UNDETERMINED\n----\n\n=== Skip rules\n\nA call that never reached a human is not worth a CRM engagement. Any of these sets\n`hubspotSyncStatus = skipped` and returns:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Skip reason | Condition\n\n| `skipped_machine_voicemail` | Disposition contains \"voicemail\", or is `Voicemail_Detected`\n| `skipped_machine_short_hangup` | `Hangup_Early` and duration `< 10` seconds\n| `skipped_screening` | `AI_Screening` and duration `<= 30` seconds\n| `skipped_machine_retarget` | Next action is `Retarget_Later` and the disposition is not `Hangup_Early`\n| `call_not_found` | No `Call` row for the id triple\n| `missing_target_relation` | The `Call` has no `CampaignTarget`\n| `no_hubspot_contact` | Neither the target's stored id nor the phone-first search resolved a contact\n|===\n\nThresholds are module constants: `HUMAN_DURATION_THRESHOLD_SECONDS = 30`,\n`SHORT_HANGUP_THRESHOLD_SECONDS = 10`.\n\n=== Owner selection\n\n. Round-robin with `require_nooks=True` when a Nooks client is configured.\n. Failing that, round-robin with `require_nooks=False`.\n. Failing that — or when the chosen rep has no `hubspotOwnerId` — use the fallback\n  owner `86817215`, logging `decision=use_fallback_hubspot_owner`.\n\nThe fallback owner is the \"AI owner\". Downstream, every re-engagement segment\ntreats a contact owned by `86817215` as effectively unowned. See\nxref:ownership.adoc[Ownership and round-robin].\n\n=== Contact resolution\n\n. `target.hubspotContactId`, when set — a direct PATCH with no search.\n. Otherwise `resolve_hubspot_contact_id_phone_first()`, searching by phone first,\n  then email and mobile phone. Multiple matches are logged at `WARNING` and the\n  first is used; `match_step` is logged so the resolution path is auditable.\n. No resolution → skip.\n\n`ensure_local_contact_row()` then guarantees a local `contacts` row exists,\nshort-circuiting at the first success:\n\n. The row is already loaded — return it.\n. Look up by `hubspotContactId` locally, repairing `target.contactId` /\n  `target.hubspotContactId` if they point elsewhere.\n. Fetch the contact from HubSpot and `upsert_for_seller()` it locally, with\n  `source = \"hubspot\"`.\n. Repair the target's foreign keys.\n\nA CRM or database failure logs a warning and returns `None`; the caller continues\nwithout a local row.\n\n=== Contact properties written\n\nAlways:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Property | Value\n\n| `ai_call_outcome` | The effective disposition\n| `hubspot_owner_id` | The selected owner\n|===\n\nAdditionally, when the disposition is `Amazon_Seller` — this call *is* the\nre-engagement signal:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Property | Value\n\n| `call_list_date` | Today (US Eastern) — the contact goes on today's list\n| `call_priority` | `\"4\"` — highest urgency\n| `call_assigned_to` | The selected owner\n|===\n\n[#lifecycle]\n=== Lifecycle promotion\n\nAlso for `Amazon_Seller` only: `maybe_promote_lifecycle_stage()` reads the CRM\n`lifecyclestage`, and writes `marketingqualifiedlead` when the current stage ranks\nlower. On success the local `contacts.lifecycleStage` is updated to match. The whole\nstep is best-effort — a failure logs and the sync continues. Ranks are in\nxref:funnel.adoc[Outbound funnel].\n\n=== The Call engagement\n\n`build_dialer_rich_summary_html()` renders an HTML body for the engagement,\nmirroring the legacy JavaScript SDK's `buildRichSummary`. It always includes the\ncall summary, then a \"RETELL AI ANALYSIS\" block: monthly revenue, disposition,\nnext action, termination reason, detected language, AI-suspicion flag, and\ndecision-maker flag. Corrected contact info, a reference name, a requested callback\ntime, and the AI agent name are appended when present.\n\nEngagement properties:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Property | Value\n\n| `hs_call_title` | `AI Call with <contact display name>`\n| `ai_call_outcome_2` | The effective disposition\n| `hubspot_owner_id` | Always the fallback owner `86817215`\n| `hs_call_disposition` | The outcome GUID for `(disposition, environment)`, when one exists\n|===\n\n[NOTE]\n====\nThe engagement is always owned by the AI agent, even when the contact and the\nfollow-up task are assigned to a human rep. This keeps the CRM honest about who\nmade the call, and it is what lets the re-engagement segments distinguish AI calls\nfrom human calls.\n====\n\nDuration is `max(0, durationSec) * 1000` milliseconds. Transcript, recording URL,\nand start timestamp are passed through.\n\nOutcome GUIDs come from `pyb_hubspot/disposition_outcomes.py`, which holds separate\nproduction and preview tables selected by `HUBSPOT_ENVIRONMENT`. Note that\n`Qualified_Lead`, `Amazon_Seller`, and `Sales_Handoff` all map to the same GUID\n(Warm Lead / AE Follow Up). A disposition with no mapping simply omits\n`hs_call_disposition`.\n\n=== Conflict handling\n\nHubSpot answering with \"already has that value\" is treated as *success*, not\nfailure: the engagement already exists with the same content, so the call is marked\n`synced` and the handler returns. Any other exception marks `failed`.\n\nOn success both the `Call` row and the `CampaignTarget` row receive\n`hubspotEngagementId` and `hubspotSyncStatus = synced`.\n\n=== Follow-up task creation\n\nCreated when any of these is true:\n\n* `call.callbackRequested`\n* `nextAction == Sales_Handoff`\n* disposition `== Qualified_Lead`\n\nOtherwise the handler logs `follow_up_skipped … reason=no_trigger` and stops.\n\nTask shape — due 24 hours out, type `CALL`, status `NOT_STARTED`, assigned to the\nselected owner:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Case | Subject | Priority\n\n| Hot lead (`Sales_Handoff` or `Qualified_Lead`) | `<Rep>: HOT LEAD - Follow Up ASAP` | `HIGH`\n| Callback requested | `<Rep>: Callback Requested: <time>` | `MEDIUM`\n|===\n\nAfter a successful create:\n\n* An `Activity` row of type `task` is inserted with `externalId = hubspotTaskId`,\n  the phone, the email, the campaign, and the target — best-effort.\n* `contacts.heatStatus` is set to `hot` — best-effort.\n\n=== Nooks enrolment\n\nOptional, and gated on all of: the fallback owner was *not* used, a Nooks client is\nconfigured, a rep was selected, that rep has a `nooksUserId`, and\n`NOOKS_DEFAULT_SEQUENCE_ID` is set. Otherwise the step is skipped with an `INFO`\nline naming the reason. Client: `lambdas/morpheus_hubspot/utils/nooks_client.py`.\n\n== Five9 sync — `Hubspot.Five9CallSync`\n\nThe human dialer has no webhook, so its call logs arrive as CSV files.\n\n[mermaid]\n....\nsequenceDiagram\n  participant EB as EventBridge\n  participant H as Five9CallSyncHandler\n  participant CFG as configs\n  participant FC as Files.com\n  participant HS as HubSpot\n  participant DB as PostgreSQL\n\n  EB->>H: Hubspot.Five9CallSync\n  H->>CFG: read five9_call_sync_watermark\n  H->>FC: list /five9/reports modified after watermark\n  loop each file\n    H->>FC: download CSV\n    loop each row\n      H->>H: parse duration, timestamp, customer phone\n      H->>DB: resolve contact (HubSpot id → local → target_phone_numbers)\n      H->>HS: create Call engagement (resolved contacts only)\n      H->>DB: upsert Call row (all calls)\n    end\n  end\n  H->>CFG: advance watermark to newest file mtime\n....\n\nFiles.com is accessed through its REST API at\n`https://app.files.com/api/rest/v1` with `FILES_COM_API_KEY`, reading the folder\n`/five9/reports`. The watermark (`five9_call_sync_watermark`, default\n`2026-01-01T00:00:00+00:00`) is the last processed file modification time. Batch\nsize is capped by `FIVE9_CALL_SYNC_BATCH_SIZE` (default 500).\n\n=== Row handling\n\n* Duration is parsed from either a `HH:MM:SS` string or a flexible numeric field.\n* The customer phone is picked out of the CSV's several phone columns and normalised\n  to E.164 by `normalize_phone()`.\n* Contact resolution goes HubSpot id → local `contacts` → `target_phone_numbers`,\n  creating a callee row where one is missing.\n* A HubSpot Call engagement is created only for *resolved* contacts. `Call` rows are\n  upserted for every row, so unmatched calls are still recorded locally.\n\n=== Disposition mapping\n\nUnlike AI dispositions, Five9's strings have near-exact HubSpot counterparts, so\n`FIVE9_DISPOSITION_TO_HUBSPOT_GUID` maps case-folded CSV values straight to\nproduction outcome GUIDs. Seventeen entries, including several many-to-one\ncollapses:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Five9 disposition | HubSpot outcome\n\n| `connected`, `correct call` | Connected\n| `left voicemail` | Left voicemail\n| `no answer`, `dial error`, `operator intercept`, `answering machine` | No answer\n| `busy` | Busy\n| `abandon`, `hangup`, `hung up` | Hung up\n| `incorrect call` | Wrong number\n| `not interested` | Not interested\n| `no longer selling` | No longer selling / shut down\n| `follow up scheduled` | Follow up scheduled\n| `warm lead ae follow up` | Warm Lead (AE Follow Up)\n| `bridge issue` | Bridge Issue\n|===\n\n`FIVE9_NOANSWER_DISPOSITIONS` — `no answer`, `busy`, `operator intercept`,\n`dial error`, `answering machine`, `left voicemail` — is used to derive HubSpot's\n`hs_call_status`.\n\n`Call` rows are upserted on `providerCallId` (`FIVE9_CALL_CONFLICT_COLUMNS`),\nupdating phone, direction, status, disposition, durations, start timestamp,\nengagement id and sync status, contact name, notes, callee row, and rep.\n\n[CAUTION]\n====\nRewinding `five9_call_sync_watermark` re-processes files. `Call` upserts are\nidempotent on `providerCallId`, but HubSpot Call engagements would be *created\nagain*. Reset only with that in mind.\n====\n\n== Disposition vocabularies\n\nThree vocabularies coexist. Confusing them is the most common source of bugs here.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Vocabulary | Values | Where\n\n| AI dispositions | `Qualified_Lead`, `Amazon_Seller`, `Sales_Handoff`, `Callback_Requested`, `Not_Qualified`, `Not_Interested`, `Wrong_Number`, `Do_Not_Call`, `Not_a_Seller`, `Hangup_Early`, `AI_Issue`, `Busy`, `AI_Screening`, `Gatekeeper`, `Voicemail_Detected`, `Undetermined` | `pyb_hubspot/disposition_outcomes.py` — `Disposition`\n| Next actions | `None`, `Retarget_Later`, `Sales_Handoff` | Same file — `NextAction`\n| Five9 dispositions | Free-text CSV strings, case-folded | `morpheus_hubspot/utils/constants.py`\n| HubSpot outcomes | GUIDs, differing per portal environment | Both files\n|===\n\nThe re-engagement views work in HubSpot *GUIDs*, because that is what the\nengagements export contains — which is why the blocked-disposition lists in\n`bigquery/*.sql` are GUID literals rather than readable names. See\nxref:re-engagement.adoc[Re-engagement segments].\n"},{"component":"morpheus","module":"business","path":"business:ownership.adoc","title":"Ownership and round-robin","description":"Who a contact is assigned to, when that assignment is overridden, and how round-robin stays fair across concurrent Lambdas.","url":"https://docx.payability.com/morpheus/business/ownership.html","navOrder":23,"source":"= Ownership and round-robin\n:description: Who a contact is assigned to, when that assignment is overridden, and how round-robin stays fair across concurrent Lambdas.\n\nOwnership answers \"whose list does this appear on?\". Getting it wrong either buries\na lead with an inactive rep or floods one rep while others idle.\n\nImplementation:\n`libs/morpheus-data-layer/morpheus_data_layer/repositories/sales_rep_round_robin.py`,\nplus the ownership block in each re-engagement handler.\n\n== The two properties\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| HubSpot contact property | Meaning\n\n| `hubspot_owner_id` | The record's actual owner. Written only when Morpheus deliberately reassigns.\n| `call_assigned_to` | Who this *call* is routed to. Can differ from the record owner — for example a deal's BDR handling a no-show while the record stays with its original owner.\n|===\n\nBoth hold a CRM owner `id` from `GET /crm/v3/owners`, mirrored locally on\n`sales_reps.hubspotOwnerId`.\n\n== The reassignment test\n\nEvery re-engagement handler runs the same three-part test. Ownership is sticky\n*unless it is not real*:\n\n[source,python]\n----\nneeds_owner = not current_owner or current_owner == DIALER_FALLBACK_HUBSPOT_OWNER_ID\nif not needs_owner:\n    needs_owner = _rep_repo.get_active_by_hubspot_owner_id(\n        session, current_owner, sub_id\n    ) is None\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Case | Interpretation\n\n| No owner | Nobody is working this contact — assign one\n| Owner is `86817215` | The AI fallback owner. A record \"owned\" by the dialer is effectively unowned by a human.\n| Owner is not an active `sales_reps` row | A departed or deactivated rep. Their leads must not be stranded.\n| Otherwise | A real, active rep owns this. Leave it alone.\n|===\n\nWhen reassignment happens, both `hubspot_owner_id` and `call_assigned_to` are set to\nthe new rep. When it does not, `hubspot_owner_id` is *not written at all* — this is\nthe guard that stops automation from stealing a human's contacts.\n\n== The AI fallback owner\n\n`DIALER_FALLBACK_HUBSPOT_OWNER_ID = \"86817215\"`\n(`lambdas/morpheus_hubspot/utils/constants.py`, duplicated as `AI_OWNER_ID` in\n`lambdas/phone_agent/utils/constants.py`).\n\nIt plays three roles:\n\n. *Owner of last resort* when the dialer sync cannot find any qualifying rep.\n. *Owner of every AI call engagement* — always, even when the contact and follow-up\n  task belong to a human rep, so the CRM records who actually made the call.\n. *A sentinel* that means \"not a human\". Every re-engagement segment treats it as\n  unowned, and the email-click segment uses it to decide that a prior call does not\n  suppress a human follow-up.\n\nThat third role is why the constant appears in five different handlers. Changing it\nrequires changing every reference, and the HubSpot portal's owner list.\n\n== Round-robin selection\n\n`select_next_sales_rep_round_robin(session, subscription_id, *, require_nooks)`:\n\n[mermaid]\n....\nflowchart TD\n  A[\"list active reps\\nrequire_hubspot=True\\nrequire_nooks=?\"] --> B{\"any reps?\"}\n  B -->|no| B1[\"log warning, return None\"]\n  B -->|yes| C[\"pg_advisory_xact_lock(subscription hash)\"]\n  C --> D[\"read cursor from configs\\ndialer_follow_up_rep_rr:<subscriptionId>\"]\n  D --> E[\"chosen = reps[cursor % len(reps)]\"]\n  E --> F[\"write cursor + 1\"]\n  F --> G[\"return chosen\\n(lock released at commit)\"]\n....\n\n=== Concurrency safety\n\nThe cursor is a row in `configs`, so two Lambdas running the same handler could read\nit simultaneously and pick the same rep. A PostgreSQL transaction-scoped advisory\nlock prevents that:\n\n[source,python]\n----\nkey = int.from_bytes(subscription_id.bytes[0:8], \"big\", signed=False) % (2**63)\nsession.execute(text(\"SELECT pg_advisory_xact_lock(:k)\"), {\"k\": key})\n----\n\nThe lock key is derived from the first eight bytes of the subscription UUID, so\ndifferent tenants never block each other. Being transaction-scoped, the lock is\nreleased automatically when the caller's `get_session()` block commits — there is\nno unlock call to forget.\n\nThe cursor is advanced in the *same transaction* as the read, so the increment is\natomic with the selection.\n\n=== Eligibility\n\n`list_active_for_subscription()` filters on:\n\n* `isActive = true`\n* `require_hubspot=True` — always, since the point is to assign a HubSpot owner, so\n  `hubspotOwnerId` must be present\n* `require_nooks` — when true, also requires `nooksUserId`\n\n`require_nooks=True` is used only by the dialer sync, and only for Nooks sequence\nenrolment. That handler tries `require_nooks=True` first and falls back to\n`require_nooks=False`, so a missing Nooks configuration degrades to HubSpot-only\nassignment rather than failing.\n\nEvery re-engagement handler passes `require_nooks=False`.\n\n=== No reps available\n\nAn empty pool logs at `WARNING` and returns `None`. Handlers diverge on what happens\nnext:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Handler | Behaviour with no rep\n\n| Email click, supplier, no-show, lost deal\n| Log a warning naming the contact and continue. The contact is still enrolled on a list — with no assignee — so the enrolment is not lost.\n\n| `Phone.CampaignTargetReEngagement`\n| Log at `ERROR` and *abort the entire batch*. Every remaining row would be enrolled unassigned, which is worse than enrolling none.\n\n| `Crm.DialerCallEnded`\n| Retry without the Nooks requirement, then fall back to owner `86817215`.\n|===\n\n=== Cursor state\n\n[source,sql]\n----\nSELECT key, value FROM configs WHERE key LIKE 'dialer_follow_up_rep_rr%';\n----\n\nThe key embeds the subscription id (`dialer_follow_up_rep_rr:<uuid>`) because\n`configs.key` is globally unique, not unique per subscription. Resetting the value\nis harmless — it only changes who is next.\n\n[NOTE]\n====\nFairness is over *selections*, not over workload. The cursor advances once per\nassignment regardless of how much work each contact represents, and adding or\nremoving an active rep changes `len(reps)` and therefore shifts the modulo mapping.\nNeither matters at this scale.\n====\n\n== Deal-owner routing\n\nTwo segments prefer the *deal's* owner over round-robin, because a deal already has\na human accountable for it:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Segment | Routing\n\n| No-show | The deal's BDR owner (`hubspot_owner_id` on the view row) goes to `call_assigned_to`, leaving `hubspot_owner_id` untouched — the call is routed without transferring the record.\n| Lost deal | Same pattern, using the deal's owner.\n|===\n\nIn both cases the deal owner still has to pass the active-rep test. A deal owned by\na departed rep falls through to round-robin.\n\n== Sales rep record\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `sales_reps` column | Purpose\n\n| `subscriptionId`, `email` | Unique together — the same person can be a rep in multiple workspaces with different CRM ids\n| `profileId` | Optional link to a Supabase user; a rep can exist before they have an account\n| `hubspotOwnerId` | CRM owner id — assigns contacts and tasks\n| `nooksUserId` | Nooks user id — assigns Nooks tasks and sequences\n| `activeListId` | HubSpot list id used for the rep's sequences\n| `hubspotCallSegmentId` | Today's dynamic call list, rewritten daily by `Hubspot.SalesRepCallListRefresh`\n| `isActive` | The eligibility flag for every selector on this page\n|===\n\nDeactivating a rep (`isActive = false`) has three immediate effects: they drop out of\nround-robin, their existing contacts become reassignable on the next re-engagement\nrun, and they stop receiving a daily call list.\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:index.adoc","title":"AgentCore runtime","description":"The containerised Strands agent runtime deployed on Amazon Bedrock AgentCore.","url":"https://docx.payability.com/morpheus/agentcore/index.html","navOrder":24,"source":"= AgentCore runtime\n:description: The containerised Strands agent runtime deployed on Amazon Bedrock AgentCore.\n\n`agentcore/` is a Python container running on Amazon Bedrock AgentCore. It exposes\none HTTP entrypoint, receives a JSON payload naming a *workflow*, executes that\nworkflow's steps against Claude on Bedrock, and returns a structured result.\n\nIt is the platform's on-demand LLM surface. Unlike the Lambda functions — which are\ntriggered by queues and schedules — the runtime is invoked by a caller who wants an\nanswer.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Language | Python {python-version}\n| Framework | `strands-agents` SDK + `bedrock_agentcore`\n| Image | `linux/arm64`, built by CDK's `DockerImageAsset`\n| Deployed by | xref:infrastructure:cdk-stack.adoc[`{cdk-stack}`]\n| Entry command | `python -m agentcore.main`\n| Log group | `/aws/bedrock-agentcore/agents/agentcore`\n| Memory | AgentCore Memory with three strategies\n|===\n\n== Pages\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Page | Contents\n\n| xref:architecture.adoc[Runtime architecture] | Startup sequence, module map, request lifecycle\n| xref:invocation.adoc[Invocation contract] | Payload shape, response shape, how to call it\n| xref:workflows.adoc[Workflows] | The registry, `BaseWorkflow`, multi-step execution, structured output\n| xref:providers.adoc[Providers and agents] | `ClaudeAgentService`, pre-wired agent singletons\n| xref:tools.adoc[Tools] | Local tools, vended Strands tools, MCP tools\n| xref:memory.adoc[AgentCore Memory] | The three strategies and how sessions attach\n| xref:configuration.adoc[Configuration] | `load_config()`, the secret schema, environment variables\n| xref:development.adoc[Development and container] | Running locally, the Dockerfile, tests\n|===\n\n== Startup in three lines\n\n`agentcore/main.py`:\n\n[source,python]\n----\nload_config()                    # 1. fetch + validate the secret, inject into os.environ, cache\nregistry = build_registry()      # 2. instantiate and register every workflow\napp = BedrockAgentCoreApp()      # 3. start the HTTP server\n----\n\nEverything else is a consequence of those three steps. `load_config()` runs first\nbecause agent singletons read credentials at import time.\n\n== Registered workflows\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Name | Payload | Description\n\n| `test` | `{\"prompt\": \"…\"}` | Direct prompt passthrough to the dialler agent\n| `dialler-agent` | `{\"phoneNumber\": \"+61412345678\"}` | Two steps: analyse HubSpot history, then prepare a structured call brief\n|===\n\nBoth are registered in `agentcore/workflows/__init__.py`. See\nxref:workflows.adoc[Workflows].\n\n== Relationship to the rest of the platform\n\nThe runtime shares the `morpheus-llm` library with the Lambda functions, so agent\nconstruction, model ids, and MCP tools behave identically in both places. It does\n*not* share code with `lambdas/` — the units never import each other.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Shared with Lambdas | Not shared\n\n| `morpheus_llm` agent services, tools, types, model ids\n| The data layer — the runtime has no database access\n\n| The Secrets Manager secret (`{shared-secret}`)\n| Event registries and handler patterns\n\n| Amazon Bedrock\n| Deployment mechanism (CDK, not SAM)\n|===\n\nThe runtime is currently the least-exercised part of the platform: no Lambda invokes\nit today. The forward-looking design that would change that is in\nxref:plans:index.adoc[Roadmap and plans].\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:architecture.adoc","title":"Runtime architecture","description":"Startup sequence, module responsibilities, and the request lifecycle inside the AgentCore container.","url":"https://docx.payability.com/morpheus/agentcore/architecture.html","navOrder":25,"source":"= Runtime architecture\n:description: Startup sequence, module responsibilities, and the request lifecycle inside the AgentCore container.\n\n== Component map\n\n[mermaid]\n....\nflowchart TB\n  subgraph container [\"AgentCore container (linux/arm64, PYTHONPATH=/app)\"]\n    Main[\"main.py\\n@app.entrypoint agent_handler()\"]\n    Cfg[\"config/loader.py\\nload_config() → AppConfig\"]\n    Reg[\"workflows/registry.py\\nWorkflowRegistry\"]\n    Base[\"workflows/base_workflow.py\\nBaseWorkflow.execute_steps()\"]\n\n    subgraph wf [\"workflows/builtin/\"]\n      PW[\"PromptWorkflow\\n'test'\"]\n      DW[\"DiallerAgentWorkflow\\n'dialler-agent'\"]\n    end\n\n    subgraph ag [\"providers/agents/\"]\n      DA[\"dialler_agent\\nClaudeAgentService singleton\"]\n    end\n\n    Types[\"types/__init__.py\\nWorkflowStep / WorkflowResult\"]\n  end\n\n  subgraph lib [\"morpheus-llm (installed dependency)\"]\n    CS[\"ClaudeAgentService\"]\n    BS[\"BaseAgentService\\ntools + memory\"]\n    Tools[\"create_hubspot_mcp()\\ncreate_tavily_mcp()\\nadd_numbers\"]\n  end\n\n  SM([\"Secrets Manager\"])\n  BR([\"Amazon Bedrock\\nClaude Sonnet 4\"])\n  MEM[(\"AgentCore Memory\")]\n  HS([\"HubSpot MCP\"])\n\n  Main --> Cfg --> SM\n  Main --> Reg --> PW & DW\n  DW --> Base\n  PW & DW --> DA --> CS --> BS\n  CS --> BR\n  BS --> MEM\n  DA --> Tools --> HS\n  Base --> Types\n....\n\n== Module responsibilities\n\n`main.py`::\nStartup and routing. Calls `load_config()`, then `build_registry()`, then starts\n`BedrockAgentCoreApp`. The `@app.entrypoint` async generator parses the payload,\nbuilds `RunOptions` from the AgentCore context, delegates to `registry.run()`, and\nyields one SSE event.\n\n`config/loader.py`::\nOwns `load_config()` — the single source of credentials. Reads `SECRET_ARN`, fetches\nfrom Secrets Manager, validates with `_SecretsSchema`, injects into `os.environ`,\nbuilds an immutable `AppConfig`, and caches it in a module global. See\nxref:configuration.adoc[Configuration].\n\n`workflows/registry.py`::\n`WorkflowRegistry` — a `dict[str, WorkflowDefinition]` with `register()`, `list()`,\nand `run()`. Also defines the `WorkflowDefinition` runtime-checkable protocol.\n\n`workflows/base_workflow.py`::\nThe multi-step execution engine: `add_step()`, `execute_steps()`, and\n`+{{variable}}+` prompt templating. One shared Strands `Agent` per invocation, so\nsteps accumulate conversation history automatically.\n\n`workflows/builtin/`::\nConcrete workflows. `PromptWorkflow` implements the protocol directly;\n`DiallerAgentWorkflow` extends `BaseWorkflow`.\n\n`providers/`::\nThin re-exports of `morpheus_llm.providers` (`BaseAgentService`,\n`ClaudeAgentService`), plus `providers/agents/` holding module-level agent\nsingletons.\n\n`types/__init__.py`::\n`WorkflowStep`, `WorkflowStepResult`, `WorkflowResult`, `InvocationPayload`, and\nre-exports of `AgentConfig`, `AgentResult`, `RunOptions` from `morpheus_llm`.\n\n`tools/builtin/`::\nLocal `@tool` functions. Production tools live in `morpheus_llm` so the Lambdas can\nuse them too.\n\n`constants.py`::\nEnvironment-driven constants: `LOG_GROUP_NAME`, `AGENT_MAX_TOKEN`, `MEMORY_ID`,\n`DEFAULT_SYSTEM_PROMPT`, `DEFAULT_AGENT_NAME`.\n\n== Startup sequence\n\n[mermaid]\n....\nsequenceDiagram\n  participant P as Python import\n  participant C as load_config()\n  participant SM as Secrets Manager\n  participant A as dialler_agent module\n  participant R as build_registry()\n  participant S as BedrockAgentCoreApp\n\n  P->>C: load_config()\n  C->>SM: GetSecretValue(SECRET_ARN)\n  SM-->>C: JSON credentials\n  C->>C: _SecretsSchema.model_validate()\n  C->>C: inject into os.environ, cache AppConfig\n  P->>R: build_registry()\n  R->>A: import agentcore.providers.agents.dialler_agent\n  A->>C: load_config() (cache hit)\n  A->>A: construct ClaudeAgentService singleton\n  R->>R: register PromptWorkflow + DiallerAgentWorkflow\n  P->>S: BedrockAgentCoreApp() then app.run()\n....\n\nOrder matters. `dialler_agent` calls `load_config()` at *module import* time to get\nHubSpot credentials for its tool factory. Because `main.py` calls `load_config()`\nbefore importing the registry, the agent module hits a warm cache. A missing\n`SECRET_ARN` therefore fails at startup with a clear `RuntimeError` rather than at\nthe first invocation.\n\n== Request lifecycle\n\n[mermaid]\n....\nsequenceDiagram\n  participant C as Caller\n  participant EP as agent_handler()\n  participant R as WorkflowRegistry\n  participant W as DiallerAgentWorkflow\n  participant B as BaseWorkflow\n  participant S as ClaudeAgentService\n  participant A as strands.Agent\n  participant M as AgentCore Memory\n  participant BR as Bedrock\n  participant H as HubSpot MCP\n\n  C->>EP: {\"workflow\":\"dialler-agent\",\"payload\":{…},\"actorId\":\"…\"}\n  EP->>EP: RunOptions(session_id=ctx.session_id, actor_id=payload.actorId)\n  EP->>R: run(\"dialler-agent\", payload, options)\n  R->>W: run(payload, options)\n  W->>B: execute_steps({\"phoneNumber\": …}, options)\n  B->>S: new_session(options)\n  S->>A: Agent(model, system_prompt, tools(), session_manager)\n  B->>A: step 1 \"analyse-history\" prompt + JSON schema\n  A->>M: retrieve memory records\n  A->>BR: inference\n  BR-->>A: tool_use\n  A->>H: HubSpot MCP call\n  H-->>A: contact + history\n  A-->>B: text → validated AnalyseHistoryOutput\n  B->>A: step 2 \"prepare-brief\" (same Agent, history intact)\n  A->>BR: inference\n  A->>M: persist turns\n  A-->>B: text → validated PrepareBriefOutput\n  B-->>W: WorkflowResult\n  W-->>EP: WorkflowResult\n  EP-->>C: SSE event {\"session_id\": …, \"data\": WorkflowResult}\n....\n\n== Two design decisions worth knowing\n\n=== Why an async generator entrypoint\n\nThe `/invocations` route uses SSE streaming. *Yielding* from the entrypoint routes\ninto the SSE-aware code path, which manages heartbeats correctly for long-running\nworkflows. A plain `return` would take the non-streaming path and risk a client\ntimeout on a multi-step workflow.\n\n=== Why a fresh Agent per invocation\n\n`ClaudeAgentService` instances are module-level singletons living for the container's\nlifetime. Constructing the Strands `Agent` in `__init__` would accumulate\n`agent.messages` across every request the container ever served, leaking one caller's\nconversation into another's. `_make_agent()` is therefore called per invocation, from\n`run()` or `new_session()`.\n\nThe same reasoning applies to MCP tools: `MCPClient` transports hold an open HTTP\nsession, so tools are supplied as a factory `lambda` and re-created per invocation.\n\n== Why `python -m agentcore.main`\n\nRunning `python agentcore/main.py` puts `agentcore/` at `sys.path[0]`, which makes\n`agentcore/types/` shadow the standard-library `types` module and breaks imports\ninside the SDKs. The `-m` form puts the working directory (`/app`) first, so\n`agentcore.types` resolves as a package attribute and the stdlib module stays\nreachable. The `dev` Make target uses `python -m agentcore.main`.\n\n[WARNING]\n====\nThe `Dockerfile` declares `CMD [\"python\", \"-m\", \"main\"]`, not\n`python -m agentcore.main`, and its build context is `agentcore/` with `COPY . ./`,\nso the package's contents land flat in `/app`. That is inconsistent with\n`main.py`'s absolute `agentcore.*` imports. Verify the container actually starts\nbefore relying on a fresh image build. See\nxref:development.adoc#container[the container section].\n====\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:invocation.adoc","title":"Invocation contract","description":"The request and response shape for the AgentCore runtime, and how to call it.","url":"https://docx.payability.com/morpheus/agentcore/invocation.html","navOrder":26,"source":"= Invocation contract\n:description: The request and response shape for the AgentCore runtime, and how to call it.\n\nCallers name a *workflow*, never a model, an agent, or a prompt template. That is\nthe whole contract, and it is what lets the runtime change models or restructure a\nworkflow's steps without touching any caller.\n\n== Request\n\n[source,json]\n----\n{\n  \"actorId\": \"+61412345678\",\n  \"workflow\": \"dialler-agent\",\n  \"payload\": { \"phoneNumber\": \"+61412345678\" }\n}\n----\n\n[cols=\"1,1,1,3\",options=\"header\"]\n|===\n| Field | Type | Required | Purpose\n\n| `workflow` | string | yes | A name registered in `build_registry()`. Unknown names raise `KeyError` listing the available workflows.\n| `payload` | object | yes | Workflow-specific input. Absent or null is treated as `{}`.\n| `actorId` | string | no | Stable per-contact identifier used for AgentCore Memory namespacing. Falls back to the session id when omitted.\n|===\n\nThe session id is *not* in the payload — it comes from the AgentCore `context`\nobject that the platform supplies.\n\n== Response\n\nOne SSE event:\n\n[source,json]\n----\n{\n  \"session_id\": \"abc123…\",\n  \"data\": {\n    \"success\": true,\n    \"output\": \"…final step's text…\",\n    \"structured_output\": { \"…\": \"validated Pydantic model from the last step\" },\n    \"stop_reason\": \"end_turn\",\n    \"usage\": { \"inputTokens\": 4210, \"outputTokens\": 890 },\n    \"error\": null,\n    \"steps\": [\n      { \"name\": \"analyse-history\", \"output\": \"…\", \"structured_output\": {…}, \"usage\": {…}, \"error\": null },\n      { \"name\": \"prepare-brief\",   \"output\": \"…\", \"structured_output\": {…}, \"usage\": {…}, \"error\": null }\n    ]\n  }\n}\n----\n\n`data` is a `WorkflowResult`. On a missing `workflow` field the entrypoint yields\n`{\"error\": \"workflow is required\"}` instead.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `WorkflowResult` field | Meaning\n\n| `success` | `false` when a step failed or payload validation failed\n| `output` | The last successful step's text output; empty string on failure\n| `structured_output` | The last step's validated Pydantic instance, when that step declared an `output_schema`\n| `stop_reason` | `end_turn` or `max_turns`\n| `usage` | Token totals summed across steps, when the provider reported them\n| `error` | Populated when `success` is `false` — includes the failing step name\n| `steps` | Per-step detail for multi-step workflows\n|===\n\n== Available workflows\n\n=== `test`\n\nDirect passthrough to the dialler agent. Useful for smoke tests and for exercising\nthe agent's tools interactively.\n\n[source,json]\n----\n{ \"workflow\": \"test\", \"payload\": { \"prompt\": \"What is 2 + 2?\" } }\n----\n\nMissing `payload.prompt` returns `success: false` with\n`\"payload.prompt is required for the prompt workflow.\"`.\n\n=== `dialler-agent`\n\nTwo sequential steps producing a call brief from a phone number.\n\n[source,json]\n----\n{ \"workflow\": \"dialler-agent\", \"payload\": { \"phoneNumber\": \"+61412345678\" } }\n----\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| # | Step | Depends on | Output schema\n\n| 1 | `analyse-history` | — | `AnalyseHistoryOutput`\n| 2 | `prepare-brief` | `analyse-history` | `PrepareBriefOutput`\n|===\n\n`PrepareBriefOutput` is the useful part for a caller:\n\n[source,python]\n----\nclass PrepareBriefOutput(BaseModel):\n    contact_summary: str\n    last_discussed: str\n    last_interaction_result: str\n    recommended_action: Literal[\"follow-up-call\", \"email\", \"sms\", \"no-action\"]\n    action_content: str | None    # draft email or SMS body\n    call_prompt: str | None       # ready-to-use call script\n    urgency: Literal[\"high\", \"medium\", \"low\"]\n----\n\nFull schemas: xref:workflows.adoc#dialler[the dialler workflow].\n\n== Calling the runtime\n\n=== AWS CLI\n\n[source,bash,subs=attributes+]\n----\naws bedrock-agentcore invoke-agent-runtime \\\n  --agent-runtime-arn arn:aws:bedrock-agentcore:{aws-region}:{aws-account}:runtime/agentcore-XXXXXXXX \\\n  --payload '{\"workflow\":\"dialler-agent\",\"payload\":{\"phoneNumber\":\"+61412345678\"}}'\n----\n\nThe ARN is the `AgentRuntimeArn` output of the CDK stack:\n\n[source,bash,subs=attributes+]\n----\naws cloudformation describe-stacks --stack-name {cdk-stack} \\\n  --query \"Stacks[0].Outputs[?OutputKey=='AgentRuntimeArn'].OutputValue\" --output text\n----\n\n=== Locally\n\n[source,bash]\n----\ncurl -X POST http://localhost:9000/invocations \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"workflow\":\"test\",\"payload\":{\"prompt\":\"Say hello.\"}}'\n----\n\nSee xref:development.adoc[Development and container].\n\n== Error behaviour\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Situation | Result\n\n| `workflow` missing | Yields `{\"error\": \"workflow is required\"}`\n| Unknown workflow name | `KeyError` from `registry.run()` listing available names — surfaces as a runtime error, not a `WorkflowResult`\n| Required payload field missing | `WorkflowResult(success=false, error=\"…\")` — validated at the top of the workflow's `run()`\n| A step raised | `WorkflowResult(success=false, error='Step \"<name>\" failed: …')`, with `steps` showing the partial results\n| A step's declared dependency did not complete | `WorkflowResult(success=false, error='Step \"<name>\": Missing dependencies: …')`\n| Structured output failed validation | The step still succeeds; `structured_output` stays `None` and the raw text is kept in `output`\n|===\n\n[NOTE]\n====\nThe last row is a deliberate choice: a schema mismatch degrades to raw text rather\nthan failing the whole workflow. A caller relying on `structured_output` must check\nfor `None`.\n====\n\n== Duplicate workflow names\n\n`WorkflowRegistry.register()` raises `ValueError` on a duplicate name. Because\nregistration happens at import time, a name collision fails the container at startup\nrather than serving ambiguous traffic.\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:workflows.adoc","title":"Workflows","description":"The workflow registry, BaseWorkflow's multi-step engine, structured output, and how to add one.","url":"https://docx.payability.com/morpheus/agentcore/workflows.html","navOrder":27,"source":"= Workflows\n:description: The workflow registry, BaseWorkflow's multi-step engine, structured output, and how to add one.\n\nA workflow is a named unit of work. It owns payload validation, prompt construction,\nstep sequencing, and result shaping. Callers know only its name — see\nxref:invocation.adoc[Invocation contract].\n\n== Types\n\n`WorkflowDefinition` protocol (`agentcore/workflows/registry.py`):\n\n[source,python]\n----\n@runtime_checkable\nclass WorkflowDefinition(Protocol):\n    name: str\n    description: str\n    def run(self, payload: dict, options: RunOptions | None = None) -> WorkflowResult: ...\n----\n\nThat is the entire requirement. Extending `BaseWorkflow` is optional and only useful\nfor multi-step workflows.\n\n[source,python]\n----\n@dataclass\nclass WorkflowStep:\n    name: str                                  # unique slug; used as a dependency key\n    prompt: str                                # supports {{variable}} placeholders\n    depends_on: list[str] = field(default_factory=list)\n    options: RunOptions | None = None           # per-step overrides\n    output_schema: Any | None = None             # Pydantic BaseModel subclass\n\n@dataclass\nclass WorkflowStepResult:\n    name: str\n    output: str | None                          # None when the step failed\n    structured_output: Any = None\n    usage: dict[str, int] | None = None\n    error: str | None = None\n\n@dataclass\nclass WorkflowResult:\n    success: bool\n    output: str\n    structured_output: Any = None\n    stop_reason: str | None = None\n    usage: dict[str, int] | None = None\n    error: str | None = None\n    steps: list[WorkflowStepResult] | None = None\n----\n\n== `WorkflowRegistry`\n\n[source,python]\n----\nclass WorkflowRegistry:\n    def register(self, *workflows: WorkflowDefinition) -> WorkflowRegistry   # chainable\n    def list(self) -> list[str]\n    def run(self, name, payload=None, options=None) -> WorkflowResult\n----\n\n* `register()` raises `ValueError` on a duplicate name — a collision fails startup.\n* `run()` raises `KeyError` with a message listing the available workflows when the\n  name is unknown.\n* One registry per process, built once by `build_registry()`.\n\n== `BaseWorkflow`\n\n`agentcore/workflows/base_workflow.py`. Register steps in the constructor, then call\n`execute_steps()` from `run()`.\n\n[source,python]\n----\nclass MyWorkflow(BaseWorkflow):\n    name = \"my-workflow\"\n    description = \"Does the thing.\"\n\n    def __init__(self) -> None:\n        super().__init__(my_agent)\n        self.add_step(WorkflowStep(name=\"fetch\", prompt=\"Fetch data for {{id}}.\"))\n        self.add_step(WorkflowStep(\n            name=\"analyse\", prompt=\"Analyse the data above.\",\n            depends_on=[\"fetch\"], output_schema=AnalysisOutput,\n        ))\n\n    def run(self, payload, options=None):\n        if not payload.get(\"id\"):\n            return WorkflowResult(success=False, output=\"\", error=\"id is required\")\n        return self.execute_steps({\"id\": payload[\"id\"]}, options)\n----\n\n=== `add_step()`\n\nChainable. Validates that every name in `depends_on` refers to an *already\nregistered* step, so forward references raise immediately at construction time —\nwhich means at container startup, not at request time.\n\n=== `execute_steps()`\n\nSteps run in registration order against a *single* Strands `Agent` obtained from\n`self._agent.new_session(options)`. Because it is one `Agent`, `agent.messages`\naccumulates across steps and each step sees the whole prior conversation with no\nmanual context threading.\n\nPer step:\n\n. Verify every `depends_on` step has completed. Any missing one fails the workflow\n  immediately with `Missing dependencies: …`.\n. Substitute `+{{variable}}+` placeholders from `variables`.\n. When `output_schema` is set, append a JSON-schema instruction to the prompt\n  (derived from `model_json_schema()`) demanding a single JSON object with no\n  markdown fences or prose.\n. Call `agent(resolved_prompt)`.\n. When `output_schema` is set, strip any markdown fences and validate with\n  `model_validate_json()`. *Validation failure is swallowed* — the raw text is kept\n  and `structured_output` stays `None`.\n. Accumulate token usage and append a `WorkflowStepResult`.\n. On an exception, stop immediately and return `success=False` naming the failing\n  step.\n\n=== `_generate_prompt()`\n\n`re.sub(r\"\\{\\{\\s*([\\w]+)\\s*\\}\\}\", …)` against the `variables` dict. Unknown keys\nsubstitute to an empty string rather than raising — a missing variable produces a\ndegraded prompt, not a crash.\n\n== Structured output\n\nTwo independent mechanisms, and it is worth knowing which is in play.\n\n`WorkflowStep.output_schema` (this module)::\n`BaseWorkflow` appends a schema instruction to the prompt text and validates the\nresponse itself. Prompt-level, best-effort, tolerant of failure.\n\n`AgentConfig.structured_output_model` (`morpheus-llm`)::\nStrands adds a dedicated output *tool*, so the model is structurally constrained and\n`AgentResult.structured_output` carries a validated instance. Stronger. Used by the\nLambda agents. See xref:libs:morpheus-llm.adoc#structured-output[structured output].\n\nThe dialler workflow uses the first; the enrichment, scoring, and email\ncategorisation agents use the second.\n\n== Built-in workflows\n\n=== `PromptWorkflow`\n\n`agentcore/workflows/builtin/prompt_workflow.py`. Implements the protocol directly —\nno `BaseWorkflow`. Takes an agent, a name, and a description at construction, so one\nclass serves any agent:\n\n[source,python]\n----\nPromptWorkflow(dialler_agent, \"test\", \"Sample test workflow\")\n----\n\nValidates `payload.prompt`, calls `self._agent.run(prompt, options)`, and wraps the\nresult. Exceptions become `WorkflowResult(success=False, error=str(exc))`.\n\n[#dialler]\n=== `DiallerAgentWorkflow`\n\n`agentcore/workflows/builtin/dialler_workflow.py`. Two steps.\n\n*Step 1 — `analyse-history`*\n\nPrompt: get contact information for `{{phoneNumber}}` from HubSpot; retrieve all\nprevious call history, transcripts, emails and other interactions; understand the\ncontent and outcome of each; identify the last interaction, open tasks, and the\ncurrent relationship status.\n\n[source,python]\n----\nclass AnalyseHistoryOutput(BaseModel):\n    contact: _ContactInfo                       # name, email, phone, company,\n                                                # hubspot_id, lifecycle_stage, hubspot_url\n    last_interaction: _LastInteraction | None    # date, type, summary\n    open_tasks: list[str] | None\n    relationship_status: str\n    interaction_history: list[_InteractionHistoryItem] | None   # date, type, summary\n----\n\n`_LastInteraction.type` is constrained to\n`call | email | meeting | note | task | other`.\n\n*Step 2 — `prepare-brief`*\n\nDepends on step 1. Produces a brief for a dialler agent, constrained to a\n`recommended_action` of `follow-up-call | email | sms | no-action` and an `urgency` of\n`high | medium | low`. Full schema in xref:invocation.adoc[Invocation contract].\n\nThe tool work all happens in step 1 through the HubSpot MCP client; step 2 is pure\nreasoning over the conversation the agent already has in context.\n\n== `build_registry()`\n\n[source,python]\n----\ndef build_registry() -> WorkflowRegistry:\n    return WorkflowRegistry().register(\n        PromptWorkflow(dialler_agent, \"test\", \"Sample test workflow\"),\n        DiallerAgentWorkflow(),\n    )\n----\n\nTakes no arguments — agents and tools are self-contained module-level singletons.\nEvery new workflow must be added here or it is unreachable.\n\n== Adding a workflow\n\n. Create `agentcore/workflows/builtin/<name>_workflow.py`.\n. Validate required payload keys at the top of `run()` and return\n  `WorkflowResult(success=False, …)` for anything missing.\n. Extend `BaseWorkflow` for multi-step; implement the protocol directly for\n  single-step.\n. Register it in `agentcore/workflows/__init__.py`.\n. Document the name and payload in xref:invocation.adoc[Invocation contract].\n\n=== Guidelines\n\n* *Use descriptive step slugs.* They appear in `WorkflowResult.steps` and in error\n  messages: `lookup-contact`, not `step1`.\n* *One task per step.* If a step's prompt has two verbs, it is two steps.\n* *Pass runtime data through `variables`*, not by string-concatenating into prompts.\n  `+{{placeholder}}+` substitution keeps the prompt readable and diffable.\n* *Declare `depends_on` honestly.* It is the only thing that guards against a step\n  running before the context it needs exists.\n* *Add an `output_schema`* to any step whose output is consumed programmatically —\n  and handle `structured_output is None`, because validation failure is non-fatal.\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:providers.adoc","title":"Providers and agents","description":"ClaudeAgentService, the pre-wired agent singletons, and how to add one.","url":"https://docx.payability.com/morpheus/agentcore/providers.html","navOrder":28,"source":"= Providers and agents\n:description: ClaudeAgentService, the pre-wired agent singletons, and how to add one.\n\nA *provider* wraps an LLM SDK behind a stable interface. An *agent* is a configured\nprovider instance — a model, a system prompt, limits, and a tool set — held as a\nmodule-level singleton.\n\nThe runtime's provider modules are thin re-exports; the implementations live in\nxref:libs:morpheus-llm.adoc[`morpheus-llm`] so the Lambda functions share them.\n\n[source,python]\n----\n# agentcore/providers/claude_service.py\nfrom morpheus_llm.providers.claude_service import ClaudeAgentService\n\n# agentcore/providers/base_service.py\nfrom morpheus_llm.providers.base_service import BaseAgentService\n----\n\n== `ClaudeAgentService`\n\nClaude on Bedrock via the Strands SDK. Authenticates through the runtime's IAM role —\nno API key.\n\n[source,python]\n----\nClaudeAgentService(\n    config: AgentConfig,\n    tools: list[Any] | Callable[[], list[Any]],\n    memory_id: str | None = None,\n)\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Argument | Purpose\n\n| `config` | `AgentConfig` — model id, system prompt, `max_tokens`, `max_turns`, optional `structured_output_model`\n| `tools` | A static list, or a zero-argument factory callable\n| `memory_id` | Overrides the `MEMORY_ID` environment variable\n|===\n\n=== `run()` versus `new_session()`\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `run(prompt, options)` | Builds a fresh `Agent`, calls it once, returns an `AgentResult`. Stateless — for single-shot prompts.\n| `new_session(options)` | Returns a fresh `Agent`. The `Agent` is directly callable; calling it repeatedly accumulates `agent.messages` automatically. Used by `BaseWorkflow.execute_steps()`.\n|===\n\n[IMPORTANT]\n====\nBoth return a *fresh* `Agent`. `ClaudeAgentService` instances are container-lifetime\nsingletons; constructing the `Agent` once in `__init__` would accumulate conversation\nhistory across every request the container served, leaking one caller's conversation\ninto another's.\n====\n\n=== Static versus factory tools\n\n[source,python]\n----\n# Static — stateless tools\nsvc = ClaudeAgentService(config, [add_numbers, shell])\n\n# Factory — required when any tool holds a stateful transport\nsvc = ClaudeAgentService(config, lambda: [add_numbers, create_hubspot_mcp(...)])\n----\n\n`MCPClient` holds an open HTTP session and cannot be shared across agent\ninvocations. Any tool set containing one *must* be a factory. See\nxref:tools.adoc[Tools].\n\n== Pre-wired agents\n\nModule-level singletons in `agentcore/providers/agents/`. Constructed once at import,\nso no per-request setup cost beyond building the `Agent`.\n\n=== `dialler_agent`\n\n`agentcore/providers/agents/dialler_agent.py`:\n\n[source,python]\n----\nconfig: AppConfig = load_config()\n\ndialler_agent = ClaudeAgentService(\n    config=AgentConfig(\n        model_id=AgentModelIds.CLAUDE_SONNET_4,\n        system_prompt=\"You are a helpful assistant that can help with dialling tasks.\",\n        max_tokens=1000,\n        max_turns=10,\n    ),\n    tools=lambda: [\n        editor,\n        shell,\n        http_request,\n        python_repl,\n        add_numbers,\n        create_hubspot_mcp(\n            client_id=config.hubspot_client_id,\n            client_secret=config.hubspot_client_secret,\n            refresh_token=config.hubspot_refresh_token,\n        ),\n    ],\n)\n----\n\nTwo things to note:\n\n* `load_config()` is called at *module import*, which is why `main.py` must call it\n  before importing the registry. It hits the cache.\n* Credentials are passed explicitly into `create_hubspot_mcp()`. The library never\n  reads `os.environ` itself.\n\n[WARNING]\n====\nThis agent has `shell` and `python_repl`, which give the model arbitrary command and\ncode execution inside the container. Acceptable for a workflow whose input is a phone\nnumber from a trusted caller; not acceptable for a workflow that ingests untrusted\ntext. See xref:ROOT:security.adoc#prompt-injection[prompt injection].\n====\n\n== Types\n\n[source,python]\n----\n@dataclass\nclass AgentConfig:\n    model_id: str\n    system_prompt: str\n    max_tokens: int\n    max_turns: int\n    structured_output_model: type | None = None\n\n@dataclass\nclass AgentResult:\n    output: str\n    stop_reason: str = \"end_turn\"          # \"end_turn\" | \"max_turns\"\n    usage: dict[str, int] | None = None\n    structured_output: Any = None\n\n@dataclass\nclass RunOptions:\n    system_prompt: str | None = None       # per-call system prompt override\n    max_turns: int | None = None\n    metadata: dict | None = None\n    session_id: str | None = None          # required for memory\n    actor_id: str | None = None            # memory namespace key\n----\n\n`session_id` and `actor_id` are what wire an invocation to AgentCore Memory — see\nxref:memory.adoc[AgentCore Memory].\n\n[#models]\n== Model ids\n\nAlways from `morpheus_llm.utils.AgentModelIds`:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Member | Model id\n\n| `CLAUDE_OPUS_4_6` | `us.anthropic.claude-opus-4-6-v1`\n| `CLAUDE_SONNET_4` | `us.anthropic.claude-sonnet-4-20250514-v1:0`\n| `CLAUDE_HAIKU_4_5` | `us.anthropic.claude-haiku-4-5-20251001-v1:0`\n| `CLAUDE_HAIKU_3_5` | `us.anthropic.claude-3-5-haiku-20241022-v1:0`\n| `CLAUDE_HAIKU_3` | `us.anthropic.claude-3-haiku-20240307-v1:0`\n| `NOVA_MICRO` | `us.amazon.nova-micro-v1:0`\n| `NOVA_LITE` | `us.amazon.nova-lite-v1:0`\n| `NOVA_2_LITE` | `us.amazon.nova-2-lite-v1:0`\n|===\n\nDo not strip the `us.` prefix — it selects the cross-region inference profile that\nnewer models require.\n\n== Adding an agent\n\n. Create `agentcore/providers/agents/<name>_agent.py` exporting a module-level\n  singleton.\n. Take model ids from `AgentModelIds`.\n. Use a `lambda` for `tools` if any tool is stateful.\n. Pass credentials explicitly from `load_config()`.\n. Reference it from a workflow — see xref:workflows.adoc[Workflows].\n\n[source,python]\n----\n# agentcore/providers/agents/research_agent.py\nfrom agentcore.config.loader import AppConfig, load_config\nfrom morpheus_llm.providers import ClaudeAgentService\nfrom morpheus_llm.tools import create_tavily_mcp\nfrom morpheus_llm.types import AgentConfig\nfrom morpheus_llm.utils import AgentModelIds\n\nconfig: AppConfig = load_config()\n\nresearch_agent = ClaudeAgentService(\n    config=AgentConfig(\n        model_id=AgentModelIds.CLAUDE_HAIKU_4_5,\n        system_prompt=\"You are a research assistant with real-time web access.\",\n        max_tokens=4096,\n        max_turns=15,\n    ),\n    tools=lambda: [create_tavily_mcp(api_key=config.tavily_api_key)],\n)\n----\n\n== Adding a provider\n\n`BaseAgentService` (in `morpheus-llm`) already owns tool resolution, memory session\nmanagement, and the `run` / `new_session` API. A new provider implements one method:\n\n[source,python]\n----\nclass MyProviderService(BaseAgentService):\n    def _make_agent(self, options: RunOptions | None = None) -> Any:\n        return SomeAgent(\n            model=...,\n            system_prompt=(options.system_prompt if options and options.system_prompt\n                           else self._config.system_prompt),\n            tools=self._resolve_tools(),\n            session_manager=self._make_session_manager(options) if options else None,\n        )\n----\n\nThe provider belongs in `libs/morpheus-llm/morpheus_llm/providers/`, not in\n`agentcore/`, so the Lambdas can use it too.\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:tools.adoc","title":"Tools","description":"How agent tools are defined, the vended Strands tools, and the MCP integrations.","url":"https://docx.payability.com/morpheus/agentcore/tools.html","navOrder":29,"source":"= Tools\n:description: How agent tools are defined, the vended Strands tools, and the MCP integrations.\n\nA tool is a callable the model can invoke during a turn to do real work. Morpheus\nuses the native `strands-agents` tool format: decorated functions passed straight\ninto an agent's `tools` list.\n\nTool implementations live in xref:libs:morpheus-llm.adoc[`morpheus-llm`] so the\nLambdas and the runtime share them. `agentcore/tools/` is a re-export layer:\n\n[source,python]\n----\n# agentcore/tools/__init__.py\nfrom morpheus_llm.tools import (\n    add_numbers, create_agentcore_browser, create_hubspot_mcp, create_tavily_mcp,\n)\n----\n\n== Defining a tool\n\nThe `@tool` decorator from `strands` derives everything from the function itself: the\ndocstring becomes the description (including the `Args:` block, which the model sees),\nand type annotations become the input schema. No separate schema object.\n\n[source,python]\n----\nfrom strands import tool\n\n@tool\ndef multiply(a: float, b: float) -> float:\n    \"\"\"Multiply two numbers together.\n\n    Args:\n        a: First operand\n        b: Second operand\n\n    Returns:\n        The product of a and b\n    \"\"\"\n    return a * b\n----\n\nThe decorated function is a Strands-native tool object and can be passed directly to\n`ClaudeAgentService`.\n\n== Local tools\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Tool | Signature | Purpose\n\n| `add_numbers` | `(a: float, b: float) -> float` | Demonstration tool, also used as a smoke test in `agentcore/test/test_main.py`\n|===\n\n== Vended Strands tools\n\nFrom `strands-agents-tools`, wired into `dialler_agent`:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Tool | Import | Capability\n\n| `editor` | `from strands_tools import editor` | Read and write files on the container filesystem\n| `shell` | `from strands_tools import shell` | Execute shell commands\n| `http_request` | `from strands_tools import http_request` | Arbitrary HTTP requests\n| `python_repl` | `from strands_tools import python_repl` | Execute Python with state persistence across calls\n|===\n\n[WARNING]\n====\n`shell` and `python_repl` give the model arbitrary command and code execution inside\nthe container. Expose them only to workflows whose inputs you control. A workflow that\nfeeds untrusted text — email bodies, scraped web content — into an agent holding these\ntools is a remote-code-execution path. See\nxref:ROOT:security.adoc#prompt-injection[prompt injection].\n====\n\n== MCP tools\n\nModel Context Protocol servers appear to the agent as tools. Both integrations are\n*factory functions*, not module-level objects, because `MCPClient` transports hold an\nopen HTTP session and cannot be reused across agent invocations.\n\n=== `create_hubspot_mcp()`\n\n[source,python]\n----\ncreate_hubspot_mcp(*, client_id: str, client_secret: str, refresh_token: str) -> MCPClient\n----\n\nConnects to the HubSpot Remote MCP server at `https://mcp.hubspot.com/` over\nStreamable HTTP. The OAuth refresh token is exchanged for an access token by\n`refresh_hubspot_access_token()` and cached with `@lru_cache(maxsize=4)` keyed on the\ncredential triple, so the OAuth round-trip happens once per container.\n\nTwo transport details:\n\n* The underlying `httpx` transport is wrapped in `_RetryOn429Transport`, which retries\n  HTTP 429 up to 4 times with exponential backoff (1 s base, capped at 30 s per\n  sleep), honouring `Retry-After` when present.\n* `terminate_on_close=False`, because HubSpot's MCP server does not implement the\n  DELETE session-termination endpoint and returns 405 if it is called.\n\nCapabilities the server advertises: contact search and retrieval, call and activity\nhistory, task management, company and deal lookups.\n\n=== `create_tavily_mcp()`\n\n[source,python]\n----\ncreate_tavily_mcp(*, api_key: str) -> MCPClient\n----\n\nConnects to `https://mcp.tavily.com/mcp/` with the API key in the query string. Two\ntools reach the agent:\n\n* `tavily-search` — web search with optional domain filtering, topic selection\n  (`general` or `news`), and result-depth control.\n* `tavily-extract` — clean article content from one or more URLs.\n\nUsed by the enrichment agent in `enrichment-scorer`, not by the runtime's\n`dialler_agent`. See xref:business:seller-enrichment.adoc[Seller enrichment].\n\n=== `create_agentcore_browser()`\n\n[source,python]\n----\ncreate_agentcore_browser(region=None, session_timeout=3600, identifier=None) -> Any\n----\n\nA fully managed cloud Chrome session through Bedrock AgentCore Browser — navigate,\nfill forms, extract content, click. Requires the optional extras\n(`pip install morpheus-llm[browser]`: `strands-agents-tools`, `playwright`,\n`nest-asyncio`); a missing dependency raises `ImportError` with the install command.\nNot currently wired into any agent.\n\n== Credentials\n\nEvery factory takes credentials as explicit keyword arguments. Libraries never read\n`os.environ`. The caller fetches them:\n\n[source,python]\n----\nconfig = load_config()                     # or load_secrets() in a Lambda\n\ntools = lambda: [\n    create_hubspot_mcp(\n        client_id=config.hubspot_client_id,\n        client_secret=config.hubspot_client_secret,\n        refresh_token=config.hubspot_refresh_token,\n    ),\n    create_tavily_mcp(api_key=config.tavily_api_key),\n]\n----\n\nThis makes credential provenance visible at the call site and keeps the library\ntestable without environment manipulation.\n\n== Adding a tool\n\n. Create `libs/morpheus-llm/morpheus_llm/tools/builtin/<name>.py`.\n. Decorate with `@tool` from `strands`; write a precise docstring.\n. Export it from `morpheus_llm/tools/__init__.py`.\n. For an MCP tool, write a *factory* taking credentials as keyword-only arguments.\n. Pass it into an agent's `tools` list — a `lambda` if it is stateful.\n. Re-export from `agentcore/tools/__init__.py` if the runtime needs it.\n\n== Guidelines\n\n* *`snake_case` names.* Models handle `get_contact_by_phone` better than\n  `getContactByPhone`.\n* *Precise docstrings.* The `Args:` descriptions are surfaced to the model. Be\n  specific about inputs, outputs, and side effects.\n* *Return strings.* Tool results go back to the model as text; return a JSON string\n  for structured data rather than a raw object.\n* *Handle errors inside the tool.* Catch exceptions and return an error *string* so\n  the model can reason about the failure and retry, rather than raising and aborting\n  the turn.\n* *Never hold state at module level.* Anything with a connection — an `MCPClient`, a\n  browser session — must be created per invocation via a factory.\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:memory.adoc","title":"AgentCore Memory","description":"The three memory strategies, how a session attaches, and what gets retrieved.","url":"https://docx.payability.com/morpheus/agentcore/memory.html","navOrder":30,"source":"= AgentCore Memory\n:description: The three memory strategies, how a session attaches, and what gets retrieved.\n\nAgentCore Memory persists conversation turns and distils them into long-term memory\nrecords that are retrieved and injected as context before each user message. It is\nwhat lets the runtime remember a contact across separate invocations.\n\nImplementation: `libs/morpheus-llm/morpheus_llm/providers/base_service.py`.\nProvisioning: `cdk/lib/stacks/agentcore-stack.ts` (`CfnMemory`).\n\n== Strategies\n\nThree strategies are configured, each writing to its own namespace:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Strategy | Name | Namespace\n\n| Semantic | `FactExtractor` | `/facts/{actorId}/`\n| User preference | `ContactDetailsInteractions` | `/contactDetails/{actorId}/`\n| Summary | `SessionSummarizer` | `/summaries/{actorId}/{sessionId}/`\n|===\n\n`{actorId}` and `{sessionId}` are resolved per invocation, so one contact's facts are\nnever retrieved for another.\n\nRetrieval configuration (`_RETRIEVAL_CONFIG` in `base_service.py`):\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Namespace | `top_k` | Contents\n\n| `/facts/{actorId}/` | 5 | Extracted facts about the contact\n| `/contactDetails/{actorId}/` | 5 | Stable preferences and contact details\n| `/summaries/{actorId}/{sessionId}/` | 3 | Summaries of earlier turns in this session\n|===\n\nEvent expiry is 90 days (`eventExpiryDuration: 90` on the `CfnMemory` resource).\n\n== How a session attaches\n\nMemory is wired only when *both* a memory id and a session id are available:\n\n[source,python]\n----\ndef _make_session_manager(self, options: RunOptions):\n    if not self._memory_id or not options.session_id:\n        return None                                  # run statelessly\n\n    actor_id = options.actor_id or options.session_id\n    memory_config = AgentCoreMemoryConfig(\n        memory_id=self._memory_id,\n        session_id=options.session_id,\n        actor_id=actor_id,\n        retrieval_config=_RETRIEVAL_CONFIG,\n        filter_restored_tool_context=True,\n    )\n    return AgentCoreMemorySessionManager(memory_config)\n----\n\nReturning `None` means the agent runs without memory rather than failing — which is\nexactly what happens in the Lambda functions, where no memory resource is configured.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Input | Source\n\n| `memory_id` | The `memory_id` constructor argument, else the `MEMORY_ID` environment variable (injected by CDK from `CfnMemory.attrMemoryId`)\n| `session_id` | `RunOptions.session_id`, set by `agent_handler()` from the AgentCore `context.session_id`\n| `actor_id` | `RunOptions.actor_id`, set from the payload's `actorId`, falling back to `session_id`\n|===\n\n`filter_restored_tool_context=True` strips restored tool-call context out of the\nreplayed conversation, so a resumed session does not re-present stale tool results as\nif they were fresh.\n\n== Choosing an `actorId`\n\n`actorId` is the memory partition key. It should be a *stable identifier for the\nperson*, so facts accumulate across sessions — a phone number in E.164, or a HubSpot\ncontact id. The `dialler-agent` workflow is invoked with the contact's phone number as\n`actorId`.\n\nOmitting it falls back to `session_id`, which makes memory effectively\nsingle-session: facts are written but can never be retrieved by a later invocation,\nbecause the next session has a different id.\n\n== IAM\n\nThe runtime role is granted, scoped to the memory ARN:\n\n[source]\n----\nbedrock-agentcore:CreateEvent\nbedrock-agentcore:ListEvents\nbedrock-agentcore:GetEvent\nbedrock-agentcore:RetrieveMemoryRecords\nbedrock-agentcore:ListMemoryRecords\nbedrock-agentcore:GetMemory\n----\n\nThe memory resource has its own execution role (`MemoryExecutionRole`) allowing\n`bedrock:InvokeModel` — the extraction and summarisation strategies are themselves\nmodel calls. See xref:infrastructure:iam.adoc[IAM].\n\n== Behaviour in the Lambda functions\n\nThe Lambdas use the same `ClaudeAgentService`, but they never set `MEMORY_ID` and\nnever pass a `session_id`, so `_make_session_manager()` returns `None` and every agent\ncall is stateless. This is intentional: enrichment, scoring, and email categorisation\nare single-shot classifications with no conversation to remember.\n\n== Known inconsistency\n\n`agentcore/.bedrock_agentcore.yaml` — the AgentCore CLI's local configuration — has\n`memory.mode: NO_MEMORY` and `memory_id: null`, while the CDK stack provisions a\nmemory resource and injects `MEMORY_ID` into the runtime. The deployed runtime uses\nmemory; a CLI-driven local run configured from that file does not. See\nxref:development.adoc[Development and container].\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:configuration.adoc","title":"Configuration","description":"load_config(), the secret schema, and the environment variables the runtime reads.","url":"https://docx.payability.com/morpheus/agentcore/configuration.html","navOrder":31,"source":"= Configuration\n:description: load_config(), the secret schema, and the environment variables the runtime reads.\n\nAll runtime configuration is resolved once at startup by\n`agentcore/config/loader.py`. The result is an immutable `AppConfig` cached in a\nmodule global for the container's lifetime.\n\nPlatform-wide configuration is in xref:ROOT:configuration.adoc[Configuration and\nsecrets]; this page covers only the runtime.\n\n== `load_config()`\n\n[source,python]\n----\ndef load_config() -> AppConfig:\n    global _cached_config\n    if _cached_config is not None:\n        return _cached_config                      # 1. cache hit\n\n    secret_arn = os.environ.get(\"SECRET_ARN\")\n    if not secret_arn:\n        raise RuntimeError(\"SECRET_ARN environment variable is not set. …\")   # 2. fail fast\n\n    sm_client = boto3.client(\"secretsmanager\", region_name=\"us-east-1\")       # 3. fetch\n    response = sm_client.get_secret_value(SecretId=secret_arn)\n    secrets = _SecretsSchema.model_validate(json.loads(response[\"SecretString\"]))  # 4. validate\n\n    os.environ[\"OPENAI_API_KEY\"] = secrets.OPENAI_API_KEY                    # 5. inject\n    if secrets.CLAUDE_API_KEY:\n        os.environ[\"ANTHROPIC_API_KEY\"] = secrets.CLAUDE_API_KEY\n    os.environ[\"HUBSPOT_CLIENT_ID\"] = secrets.HUBSPOT_CLIENT_ID\n    os.environ[\"HUBSPOT_CLIENT_SECRET\"] = secrets.HUBSPOT_CLIENT_SECRET\n    os.environ[\"HUBSPOT_REFRESH_TOKEN\"] = secrets.HUBSPOT_REFRESH_TOKEN\n    if secrets.TAVILY_API_KEY:\n        os.environ[\"TAVILY_API_KEY\"] = secrets.TAVILY_API_KEY\n\n    _cached_config = AppConfig(...)                                          # 6. build + cache\n    return _cached_config\n----\n\nTwo things to note:\n\n* `SECRET_ARN` is *mandatory*. Its absence raises at import time, so a\n  misconfiguration surfaces at container start rather than at the first invocation.\n* The region is hardcoded to `us-east-1` for the Secrets Manager client, not read from\n  `AWS_REGION`.\n\n`CLAUDE_API_KEY` is injected under the name `ANTHROPIC_API_KEY`, which is what the\nAnthropic SDK looks for. It is unnecessary on Bedrock, where the IAM role\nauthenticates.\n\n== Secret schema\n\n`_SecretsSchema` — only these keys are read by the runtime; everything else in the\nshared secret is ignored.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Key | Required | Purpose\n\n| `OPENAI_API_KEY` | *yes* | Validated non-empty. Reserved for a future OpenAI provider.\n| `HUBSPOT_CLIENT_ID` | *yes* | Validated non-empty\n| `HUBSPOT_CLIENT_SECRET` | *yes* | Validated non-empty\n| `HUBSPOT_REFRESH_TOKEN` | *yes* | Validated non-empty\n| `CLAUDE_API_KEY` | no | Injected as `ANTHROPIC_API_KEY` when present\n| `TAVILY_API_KEY` | no | Injected when present\n|===\n\n[IMPORTANT]\n====\n`OPENAI_API_KEY` is required and validated non-empty even though no OpenAI provider\nexists. A secret without it fails the container's cold start. Populate it with a\nplaceholder if you have no OpenAI account, or relax the schema.\n====\n\nA `field_validator` rejects whitespace-only values for the four required keys, so a\nblank string fails as loudly as a missing key.\n\n== `AppConfig`\n\n[source,python]\n----\nclass AppConfig(BaseModel):\n    # credentials (also injected into os.environ)\n    openai_api_key: str\n    claude_api_key: str | None\n    hubspot_client_id: str\n    hubspot_client_secret: str\n    hubspot_refresh_token: str\n    tavily_api_key: str | None\n    # provider defaults\n    provider: str\n    model_id: str\n    agent_name: str\n    system_prompt: str\n    max_tokens: int\n    max_turns: int\n----\n\nAgent modules read the credential fields from this object rather than from\n`os.environ`, and pass them explicitly into tool factories. See\nxref:tools.adoc[Tools].\n\n== Environment variables\n\n=== Required\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Variable | Source\n\n| `SECRET_ARN` | Injected by the CDK stack when `secretArn` is set on the stack props\n|===\n\n=== Injected by CDK\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AWS_REGION` | The stack's region\n| `LOG_GROUP_NAME` | `/aws/bedrock-agentcore/agents/agentcore`\n| `MEMORY_ID` | `CfnMemory.attrMemoryId` — enables xref:memory.adoc[AgentCore Memory]\n|===\n\n=== Optional provider defaults\n\nRead by `load_config()`, each with a code fallback:\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Variable | Default | Effect\n\n| `DEFAULT_AGENT_PROVIDER` | `claude` | Selects the default model table entry\n| `DEFAULT_MODEL_ID` | `anthropic.claude-3-5-sonnet-20241022-v2:0` (from `PROVIDER_DEFAULT_MODEL[\"claude\"]`) | `AppConfig.model_id`\n| `DEFAULT_AGENT_NAME` | `AgentCore` | Display name\n| `DEFAULT_SYSTEM_PROMPT` | `You are a helpful, accurate, and concise AI assistant.` | Fallback system prompt\n| `DEFAULT_MAX_TOKENS` | `4096` | `AppConfig.max_tokens`\n| `DEFAULT_MAX_TURNS` | `10` | `AppConfig.max_turns`\n|===\n\n[NOTE]\n====\nThese defaults are currently *unused by the registered workflows*. Both agents carry\ntheir own explicit `AgentConfig` — `dialler_agent` pins\n`AgentModelIds.CLAUDE_SONNET_4`, 1000 tokens, 10 turns. `AppConfig`'s provider\ndefaults matter only to code that reads them, and nothing does today.\n\nNote also that `PROVIDER_DEFAULT_MODEL[\"claude\"]` lacks the `us.` prefix, so it would\nnot resolve as a cross-region inference profile if it were used.\n====\n\n=== Read by `constants.py`\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Constant | Variable | Default\n\n| `LOG_GROUP_NAME` | `LOG_GROUP_NAME` | `/aws/bedrock-agentcore/agents/default`\n| `AGENT_MAX_TOKEN` | `MAX_TOKENS` | `4096`\n| `MEMORY_ID` | `MEMORY_ID` | `\"\"` (empty disables memory)\n| `DEFAULT_SYSTEM_PROMPT` | — | hardcoded\n| `DEFAULT_AGENT_NAME` | — | `AgentCore`\n|===\n\n== Rotating credentials\n\nThe secret is cached in a module global for the container's lifetime. A rotated value\ntakes effect on the next cold start. Force one by redeploying, or by updating the\nruntime configuration so the platform recycles the container.\n\n== Adding a secret key\n\n. Add the key to the Secrets Manager secret.\n. Add the field to `_SecretsSchema` in `agentcore/config/loader.py` — optional unless\n  the runtime genuinely cannot start without it.\n. Inject it into `os.environ` only if a third-party SDK needs to auto-discover it.\n. Add it to `AppConfig` and populate it in the `_cached_config` constructor.\n. Pass it explicitly to whatever needs it, rather than having that code read\n  `os.environ`.\n. Document it in xref:ROOT:configuration.adoc#secret[the shared secret].\n"},{"component":"morpheus","module":"agentcore","path":"agentcore:development.adoc","title":"Development and container","description":"Running the runtime locally, the Docker image, tests, and the AgentCore CLI configuration.","url":"https://docx.payability.com/morpheus/agentcore/development.html","navOrder":32,"source":"= Development and container\n:description: Running the runtime locally, the Docker image, tests, and the AgentCore CLI configuration.\n\n== Prerequisites\n\nPython {python-version}, `uv`, Docker (for image builds), and AWS credentials with\naccess to the Secrets Manager secret and Bedrock.\n\n[source,bash]\n----\nmake install          # uv sync --directory agentcore\n----\n\nDependencies (`agentcore/pyproject.toml`): `bedrock-agentcore`, `strands-agents`,\n`strands-agents-tools`, `boto3`, `pydantic>=2`, and\n`morpheus-llm[browser]` — resolved from `../libs/morpheus-llm` as an editable path\ndependency, so a change to the library is picked up without a reinstall.\n\n== Running locally\n\n[source,bash]\n----\nexport SECRET_ARN=arn:aws:secretsmanager:us-east-1:625663093496:secret:agentcore-secrets-XXXXXX\nmake dev              # uv run --directory agentcore python -m agentcore.main\n----\n\n[CAUTION]\n====\nAlways `python -m agentcore.main`. Running `python agentcore/main.py` puts\n`agentcore/` at `sys.path[0]`, so `agentcore/types/` shadows the standard-library\n`types` module and imports inside the SDKs break.\n====\n\nThis starts the `BedrockAgentCoreApp` HTTP server on port 9000. It needs real AWS\ncredentials: the secret is fetched from Secrets Manager and inference goes to Bedrock.\n\n[source,bash]\n----\ncurl -X POST http://localhost:9000/invocations \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"workflow\":\"test\",\"payload\":{\"prompt\":\"What is 2 + 2?\"}}'\n----\n\nBecause `MEMORY_ID` is unset locally, agents run without memory — see\nxref:memory.adoc[AgentCore Memory].\n\n== Tests\n\n[source,bash]\n----\nmake test             # uv run pytest agentcore/test/ -v\n----\n\n`agentcore/test/test_main.py` covers `add_numbers`, `AgentModelIds` values, registry\nregister/list/run plus duplicate and unknown-name errors, `PromptWorkflow` success and\nmissing-prompt paths, `BaseWorkflow._generate_prompt()` and its unknown-dependency\nguard, and that `load_config()` raises without `SECRET_ARN`. AWS is mocked throughout\nwith `unittest.mock`.\n\n[WARNING]\n====\n`test_agent_model_ids` asserts `AgentModelIds.CLAUDE_SONNET_3_5`, which no longer\nexists in `morpheus_llm.utils.enums.AgentModelIds` — the enum has `CLAUDE_HAIKU_3_5`\nbut no Sonnet 3.5 member. That test fails until it is updated.\n====\n\nRun a single test:\n\n[source,bash]\n----\nuv run pytest agentcore/test/test_main.py::test_registry_run -v\n----\n\n`pytest` is configured with `asyncio_mode = \"auto\"` and `pythonpath = [\".\"]` in\n`agentcore/pyproject.toml`.\n\n[#container]\n== The container image\n\n`agentcore/Dockerfile`:\n\n[source,dockerfile]\n----\nFROM python:3.12-slim\nWORKDIR /app\n\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\n\nCOPY pyproject.toml uv.lock* ./\nRUN uv sync --frozen --no-dev\nENV PATH=\"/app/.venv/bin:$PATH\"\n\nCOPY . ./\nENV PYTHONPATH=/app\nEXPOSE 9000 8000 8080\nCMD [\"python\", \"-m\", \"main\"]\n----\n\n* Single stage — Python needs no build step.\n* `uv sync --frozen --no-dev` installs production dependencies from the lockfile\n  before the source is copied, so a source-only change reuses the dependency layer.\n* `PATH` puts the virtual environment's Python first.\n* `PYTHONPATH=/app` supports absolute imports.\n\n[WARNING]\n====\nTwo inconsistencies to be aware of before relying on a fresh image:\n\n* `CMD` is `python -m main`, but `main.py` imports `from agentcore.config.loader\n  import load_config`. The CDK build context is `agentcore/` with `COPY . ./`, so the\n  package's *contents* land flat in `/app` and there is no `agentcore/` directory\n  inside the image for those imports to resolve against.\n* `uv sync --frozen --no-dev` runs before `COPY . ./`, so the project's own source is\n  absent when its editable install would be performed.\n\nNeither has been reconciled in this repository. If a container deploy fails at\nstartup, this is the first place to look.\n====\n\n=== Building and running by hand\n\n[source,bash]\n----\ncd agentcore\ndocker build --platform linux/arm64 -t agentcore:local .\n\ndocker run --rm \\\n  -e SECRET_ARN=\"$SECRET_ARN\" \\\n  -e AWS_REGION=us-east-1 \\\n  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \\\n  -p 9000:9000 \\\n  agentcore:local\n----\n\n`linux/arm64` matches the platform CDK targets (`Platform.LINUX_ARM64`). In normal\noperation you never build by hand: `cdk deploy` builds and pushes the image through\n`DockerImageAsset`.\n\n=== Excluded from the image\n\n`agentcore/.dockerignore` excludes `.env`, `.venv/`, `test/`, and\n`.bedrock_agentcore.yaml`. Secrets are always fetched at runtime; tests and CLI\nconfiguration have no place in a production image.\n\n== `.bedrock_agentcore.yaml`\n\nThe AgentCore CLI's local configuration. It references the CDK-managed runtime and\nrole rather than creating its own:\n\n[source,yaml]\n----\ndefault_agent: agentcore\nagents:\n  agentcore:\n    entrypoint: ./agentcore/main.py\n    deployment_type: container\n    platform: linux/amd64\n    source_path: ./agentcore\n    aws:\n      execution_role: arn:aws:iam::625663093496:role/AgentCoreStack-agentcoreRuntimeRole…\n      execution_role_auto_create: false\n      account: '625663093496'\n      region: us-east-1\n      network_configuration: { network_mode: PUBLIC }\n      protocol_configuration: { server_protocol: HTTP }\n      observability: { enabled: true }\n    bedrock_agentcore:\n      agent_id: agentcore-Xt6rGY2nKW\n      agent_arn: arn:aws:bedrock-agentcore:us-east-1:625663093496:runtime/agentcore-Xt6rGY2nKW\n    memory:\n      mode: NO_MEMORY\n----\n\nAlways update `execution_role` from the CDK stack's `RuntimeRoleArn` output after a\ndeploy. Do not let the CLI create its own role — CDK owns all IAM.\n\n[NOTE]\n====\nTwo fields diverge from the deployed stack: `platform` is `linux/amd64` here while CDK\nbuilds `linux/arm64`, and `memory.mode` is `NO_MEMORY` while the stack provisions a\nmemory resource and injects `MEMORY_ID`. The CLI path and the CDK path are not\ncurrently equivalent.\n====\n\n== Adding a dependency\n\n[source,bash]\n----\ncd agentcore && uv add <package>\n----\n\nNever `pip install`. `uv add` updates both `pyproject.toml` and `uv.lock`, and the\nlockfile is what `uv sync --frozen` in the Dockerfile installs from — an unlocked\ndependency will not be in the image.\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:index.adoc","title":"Lambda functions","description":"The five Lambda functions that make up the Morpheus pipeline, and what each owns.","url":"https://docx.payability.com/morpheus/lambdas/index.html","navOrder":33,"source":"= Lambda functions\n:description: The five Lambda functions that make up the Morpheus pipeline, and what each owns.\n\nFive Python {python-version} functions, all defined in one SAM template\n(`lambdas/template.yaml`), all built from the same internal shape: a dispatcher, a\nregistry, and one handler module per event type.\n\n== The five functions\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Function | Physical name | Triggers | Owns\n\n| xref:enrichment-scraper.adoc[enrichment-scraper]\n| `{sam-stack}-enrichment-scraper`\n| EventBridge schedules\n| Seller discovery from SmartScout; BigQuery contact sync\n\n| xref:enrichment-scorer.adoc[enrichment-scorer]\n| `{sam-stack}-enrichment-scorer`\n| `EnrichmentPipelineQueue` + schedules\n| Seller persistence, AI enrichment, payout-pain scoring\n\n| xref:morpheus-hubspot.adoc[morpheus-hubspot]\n| `{sam-stack}-hubspot-sync`\n| `CrmSyncQueue` + 9 schedules\n| Every HubSpot write; re-engagement segmentation; engagement and call sync\n\n| xref:email-agent.adoc[email-agent]\n| `{sam-stack}-email-agent`\n| `EmailAgentQueue`\n| Inbound reply categorisation\n\n| xref:phone-agent.adoc[phone-agent]\n| `{sam-stack}-phone-agent`\n| 3 schedules\n| Twilio line intelligence; Slack task reminders and escalation; campaign re-engagement\n|===\n\n== Resource configuration\n\n[cols=\"1,1,1,1,2\",options=\"header\"]\n|===\n| Function | Timeout | Memory | Reserved concurrency | Layers\n\n| enrichment-scraper | 300 s | 256 MB | — | common, data, bigquery\n| enrichment-scorer | 120 s | 512 MB | 5 | common, data, llm, pyb-hubspot\n| morpheus-hubspot | 300 s | 256 MB | — | common, data, pyb-hubspot, bigquery\n| email-agent | 120 s | 512 MB | 5 | common, data, llm\n| phone-agent | 120 s | 256 MB | 2 | common, data, bigquery\n|===\n\nAll are `x86_64` (`Globals.Function.Architectures`). Timeouts are chosen to sit below\nthe source queue's `VisibilityTimeout` where one applies — 120 s against 180 s for the\nenrichment and email queues, 300 s against 360 s for `CrmSyncQueue`.\n\nSee xref:infrastructure:layers.adoc[Lambda layers] and\nxref:infrastructure:sam-template.adoc[SAM template].\n\n== Shape of a function\n\n[source]\n----\nlambdas/<function>/\n├── handler.py        lambda_handler — dispatcher only, no business logic\n├── registry.py       routing key → (handler class, Pydantic model)\n├── events/           one module per event type\n│   ├── base.py       the abstract handler base class\n│   └── samples/      example payloads for sam local invoke and tests\n├── models/\n│   ├── config.py     pydantic-settings Settings — environment variables\n│   ├── secrets.py    load_secrets() — @lru_cache over the shared secret\n│   └── events.py     event payload models local to this function\n├── services/         integrations and orchestration used by handlers\n├── utils/            constants, logging, pure helpers\n├── pyproject.toml    dependencies (uv)\n└── requirements.txt  exported deploy manifest\n----\n\nThe pattern is uniform enough that reading one function teaches you all five. Start\nwith xref:handler-pattern.adoc[Handler and registry pattern].\n\n== Cold start\n\nEvery `handler.py` runs the same three steps at module import, in this order:\n\n[source,python]\n----\nconfigure_sentry()                                # 1. no-op when SENTRY_DSN is empty\n_secrets = load_secrets()                         # 2. one Secrets Manager call, @lru_cache\ninit_db(_secrets.database_url, pool_size=0)       # 3. NullPool engine\n----\n\n`pool_size=0` selects SQLAlchemy's `NullPool`. Each Lambda container handles one\ninvocation at a time and gains nothing from a persistent pool, while NullPool lets\nSupabase's transaction-mode pooler multiplex efficiently across hundreds of\ncontainers. See xref:libs:morpheus-data-layer.adoc#pooling[pooling].\n\n== Cross-function communication\n\nOnly through queues. No function calls another, and no function imports another's\ncode.\n\n[mermaid]\n....\nflowchart LR\n  Scraper[\"enrichment-scraper\"] -->|SellerRecord.Received| Q1[[\"EnrichmentPipelineQueue\"]]\n  Q1 --> Scorer[\"enrichment-scorer\"]\n  Scorer -->|Seller.CrmSync| Q2[[\"CrmSyncQueue\"]]\n  Phone[\"phone-agent\"] -->|Contact.Update| Q2\n  Phone -->|FollowUpTask.Sync| Q2\n  Phone -->|Email.SendInternal| Q3[[\"EmailAgentQueue\"]]\n  Q2 --> Hub[\"morpheus-hubspot\"]\n  Q3 --> Email[\"email-agent\"]\n  Ext([\"web application / webhook relays\"]) --> Q2\n  Ext --> Q3\n....\n\nThe full inventory is in xref:events:catalog.adoc[Event catalog].\n\n== Where the logic is concentrated\n\n`morpheus-hubspot` is by far the largest function: 12 SQS event types, 9 scheduled\nhandlers, and the two heaviest modules in the repository\n(`services/dialer_call_sync.py` at 688 lines and\n`events/scheduled/five9_call_sync.py` at 675). It is the single writer to HubSpot,\nwhich is why so much converges there.\n\n`phone-agent` is the second-densest, mostly because task escalation and campaign\nre-engagement are both non-trivial state machines.\n\n== Testing\n\n[source,bash]\n----\ncd lambdas && uv run pytest tests/ -v                     # cross-function\ncd lambdas && uv run pytest morpheus_hubspot/tests -v     # hubspot handlers\ncd lambdas/enrichment_scraper && uv run pytest tests -v   # SmartScout provider\n----\n\nSee xref:testing.adoc[Testing Lambdas].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:handler-pattern.adoc","title":"Handler and registry pattern","description":"The uniform dispatcher, registry, and handler structure shared by all five Lambda functions.","url":"https://docx.payability.com/morpheus/lambdas/handler-pattern.html","navOrder":34,"source":"= Handler and registry pattern\n:description: The uniform dispatcher, registry, and handler structure shared by all five Lambda functions.\n\nEvery function separates three concerns absolutely:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Layer | Responsibility\n\n| `handler.py` | Detect the event shape, resolve the routing key, validate the payload, call the handler. No business logic, ever.\n| `registry.py` | Map routing keys to `(handler class, Pydantic model)` pairs. Nothing else.\n| `events/<name>.py` | One event type's business logic, in a class extending the function's base handler.\n|===\n\nAdding behaviour therefore never means editing the dispatcher.\n\n== The dispatcher\n\n[source,python]\n----\ndef lambda_handler(event: dict, context) -> dict | None:\n    records = event.get(\"Records\", [])\n    if records and records[0].get(\"eventSource\") == \"aws:sqs\":\n        return _handle_sqs_batch(records, context)      # → {\"batchItemFailures\": [...]}\n\n    detail_type = event.get(\"detail-type\", \"\")\n    if detail_type:\n        return _handle_eventbridge(event, detail_type, context)   # → None\n\n    logger.warning(\"Unrecognised event shape — neither SQS batch nor EventBridge\")\n    return None\n----\n\nShape detection is by inspection, not configuration, so one function can serve both an\nSQS queue and any number of schedules.\n\n=== SQS path\n\nPer record:\n\n. Read the routing key from the `event_type` *message attribute*.\n. Look it up in the registry. Unknown key → log a warning, `continue`; the message is\n  deleted. Retrying an unroutable message cannot help.\n. `json.loads` the body. Failure → add to `batchItemFailures` (the body is malformed,\n  so it belongs in the DLQ).\n. `model_validate()` the body. `ValidationError` → add to `batchItemFailures`.\n. Instantiate the handler and call `handle(parsed, context)`. An exception → log with\n  `traceback` and add to `batchItemFailures`.\n\nReturns `{\"batchItemFailures\": [{\"itemIdentifier\": messageId}, …]}`. Every SQS trigger\ndeclares `FunctionResponseTypes: [ReportBatchItemFailures]`, so a failure isolates to\nthe offending message.\n\n=== EventBridge path\n\n. Read `detail-type` from the event root.\n. Look it up in the scheduled registry. Unknown → log a warning and return.\n. `model_validate()` the *whole event* (not a body) — scheduled models are\n  `extra=\"ignore\"` so the envelope's extra fields are tolerated. A `ValidationError`\n  logs the errors plus a 500-character raw preview, then *re-raises*.\n. Call the handler. An exception is logged with `traceback` and re-raised.\n\nScheduled failures propagate because there is no queue to absorb them; the failure\nshould be visible in the function's error metric.\n\n== Per-function variations\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Function | Variation\n\n| `morpheus-hubspot`\n| Two separate registries: `event_registry` (SQS) and `scheduled_registry` (EventBridge). The routing key comes from the `event_type` field *inside the message body*, not from a message attribute. A body may also be a JSON *array* of event objects, each dispatched individually; if any element fails, the whole message is marked failed. An unknown `event_type` returns `True` (success) so the message is discarded.\n\n| `enrichment-scorer`\n| A single registry serves both SQS and EventBridge. Falls back to a default routing key `SellerRecord.Received` when the message attribute is absent, for backward compatibility with messages published before the attribute existed.\n\n| `email-agent`, `phone-agent`\n| A missing `event_type` message attribute logs a warning and skips the record.\n\n| `enrichment-scraper`\n| EventBridge only — no SQS path at all.\n|===\n\n== Registry shapes\n\nTwo styles, functionally equivalent.\n\nDataclass registry — scraper, scorer, email-agent, phone-agent::\n[source,python]\n----\n@dataclass\nclass EventRegistration:\n    handler_class: type[BaseEventHandler]\n    event_model: type[BaseModel]\n\n@dataclass\nclass EventRegistry:\n    _registry: dict[str, EventRegistration] = field(default_factory=dict)\n    def register(self, key, handler_class, event_model) -> None: ...\n    def lookup(self, key) -> EventRegistration | None: ...\n    def registered_types(self) -> list[str]: ...\n\ndef build_registry() -> EventRegistry:\n    registry = EventRegistry()\n    registry.register(\"SellerRecord.Received\", SellerReceivedHandler, SellerRecord)\n    return registry\n\nevent_registry: EventRegistry = build_registry()     # module-level singleton\n----\n+\nHandlers are stored as *classes* and instantiated per message.\n\nPlain dict registry — morpheus-hubspot::\n[source,python]\n----\nevent_registry: dict = {\n    \"Seller.CrmSync\": (SellerCrmSyncHandler(), SellerSyncEvent),\n    \"Contact.Update\": (ContactUpdateHandler(), ContactUpdateEvent),\n    # …\n}\n----\n+\nHandlers are stored as *instances*, constructed once at import. The module carries a\n`# TODO: Class should be defined and not initialized here` noting the divergence.\n\nEither way `registered_types()` — or the dict's keys — is what appears in the \"no\nhandler registered\" log line, which makes a typo in a `detail-type` easy to diagnose.\n\n== Base handler classes\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Function | Base class\n\n| enrichment-scraper, morpheus-hubspot | `BaseEventHandler`\n| enrichment-scorer | `BaseEventHandler`, and `BaseEnrichmentHandler` for enrichment work\n| email-agent | `BaseEmailEventHandler`\n| phone-agent | `BasePhoneEventHandler`\n|===\n\nAll are the same two lines:\n\n[source,python]\n----\nclass BaseEventHandler(ABC):\n    @abstractmethod\n    def handle(self, event: BaseModel, context: Any) -> None: ...\n----\n\n`BaseEnrichmentHandler` is the exception — it carries real shared behaviour:\nclass-level repositories plus `_enrich()`, `_apply_enrichment()`, `_score()`,\n`_apply_score()`, and `_publish_crm_sync()`. Its subclasses differ only in how they\n*source* sellers (an SQS message versus a database query) and call the inherited steps\nin whatever order their trigger requires. See\nxref:enrichment-scorer.adoc[enrichment-scorer].\n\n== Logging\n\nBoth dispatch paths call `configure_logging(level, aws_request_id, event_type)` before\nhanding off, so every line in an invocation carries the request id and the routing key.\nImplementations live in each function's `utils/logging.py`.\n\n== Failure semantics\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Situation | Message fate | Rationale\n\n| Unknown routing key | deleted | Retrying cannot route it\n| Malformed JSON body | DLQ | The producer is broken; keep the evidence\n| `ValidationError` | DLQ | Same\n| Subscription mismatch | deleted (handler returns) | A poison message from a misconfigured producer must not loop\n| Missing database row | deleted (handler returns) | Nothing to act on\n| Handler raised | DLQ | Possibly transient; keep it for inspection or replay\n| Scheduled handler raised | n/a | Propagates; the next tick retries\n|===\n\nThe distinction that matters: *bad data returns normally, transient failure raises*.\nGetting it backwards either loses real work or fills a DLQ with unfixable messages.\n\n== Adding an event type\n\n. Create `events/<name>.py` with a handler extending the function's base class.\n. Define or reuse a Pydantic model — in `models/` for a function-local event, or in\n  `morpheus_common.events.models` for anything crossing a function boundary.\n. Register the routing key in `registry.py`.\n. Add the trigger to `lambdas/template.yaml` — an SQS `Events` entry, or a `Schedule`\n  whose `Input` `detail-type` matches the registry key *exactly*.\n. Add a sample payload under `events/samples/` and a test.\n\nFull walkthrough with code: xref:events:adding-an-event.adoc[Adding a new event].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:provider-pattern.adoc","title":"Provider pattern","description":"The BaseProvider abstraction, the SmartScout implementation in full, and how to add a second seller source.","url":"https://docx.payability.com/morpheus/lambdas/provider-pattern.html","navOrder":35,"source":"= Provider pattern\n:description: The BaseProvider abstraction, the SmartScout implementation in full, and how to add a second seller source.\n\nA *provider* is a class that turns some external seller catalogue into a list of\n`SellerRecord` objects. `enrichment-scraper` has one — `SmartScoutProvider` — and the\nabstraction exists so a second can be added without touching the dispatcher, the registry, the\nqueue service, or any existing provider.\n\nThe split is deliberate: everything that is the *same* for every HTTP seller API lives in the\nbase class, and everything that is specific to one vendor lives in the subclass.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Owner | Concern\n\n| `BaseProvider`\n| Retries, timeout backoff, 401 re-authentication, 429 throttling, 5xx handling, proactive\n  rate-limit sleeping, and safe row mapping\n\n| The subclass\n| Request headers, pagination, request-body shape, and mapping one raw row to a `SellerRecord`\n|===\n\n== BaseProvider\n\n`lambdas/enrichment_scraper/providers/base.py`. Generic over the raw row type, so a subclass\ndeclares the Pydantic model its API returns:\n\n[source,python]\n----\nRawSellerT = TypeVar(\"RawSellerT\", bound=BaseModel)\n\nclass BaseProvider(ABC, Generic[RawSellerT]):\n    ...\n\nclass SmartScoutProvider(BaseProvider[SmartScoutSeller]):\n    ...\n----\n\n=== Abstract methods\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Contract\n\n| `fetch_sellers(filters) -> list[SellerRecord]`\n| Paginate the source and return matching records.\n\n| `_to_seller_record(raw) -> SellerRecord`\n| Map one raw API row to the domain model. May raise `ValidationError`.\n\n| `_build_request_headers() -> dict[str, str]`\n| Full headers for every outbound request — auth, content type, fingerprint, tracing ids.\n|===\n\n[NOTE]\n====\n`SmartScoutProvider` widens the `fetch_sellers` signature to\n`fetch_sellers(filters, start_from_row=0) -> tuple[list[SellerRecord], int]`, returning the\nnext cursor alongside the records. The base class's narrower declaration is the older\ncontract; the tuple form is what the handler actually calls, because discovery has to resume\nacross scheduled runs. A second provider should follow the tuple form.\n====\n\n=== Overridable hook\n\n`_on_unauthorized()` is called on HTTP 401 before the single retry. The default is a no-op;\n`SmartScoutProvider` overrides it to call `self._auth.invalidate()`.\n\nThis is the seam that makes token refresh work without the base class knowing anything about\ntokens. `_post_with_retry()` rebuilds headers on *every* attempt, so invalidating the cache in\nthe hook is enough — the retry picks up a fresh token through `_build_request_headers()`.\n\n=== Inherited methods\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `map_seller(raw)`\n| Wraps `_to_seller_record()`, catching `ValidationError`, logging it, and returning `None`\n  so the caller can skip the row.\n\n| `_post_with_retry(url, body, retry_count=0)`\n| POST with the full failure matrix below.\n\n| `_handle_rate_limit_headers(response)`\n| Sleeps until the reset time when `X-Rate-Limit-Remaining` is 1 or 0.\n\n| `_parse_rate_limit_reset(header_value)`\n| Parses `X-Rate-Limit-Reset` into seconds to sleep.\n|===\n\n=== SellerFilters\n\nThe universal filter contract. Each provider translates it into whatever its API wants.\n\n[source,python]\n----\n@dataclass\nclass SellerFilters:\n    monthly_revenue_min: float | None = None\n    monthly_revenue_max: float | None = None\n    categories: list[str] = field(default_factory=list)\n    marketplace: str = \"amazon\"\n----\n\nRevenue is **monthly** here, while the `ICP_REVENUE_MIN` / `ICP_REVENUE_MAX` settings are\nannual — the handler divides by twelve when building the filters. See\nxref:business:seller-discovery.adoc[Seller discovery].\n\n[#failure-matrix]\n=== Failure handling\n\n`_post_with_retry()` treats each failure class differently, and the distinctions are the point\nof the method:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Condition | Behaviour\n\n| `httpx.TimeoutException`\n| Exponential backoff — `HTTP_RETRY_BASE_SECONDS * 2^retry` — up to `HTTP_MAX_RETRIES`, then\n  skip the page.\n\n| HTTP 401\n| Call `_on_unauthorized()`, retry **once** with rebuilt headers. A second 401 skips the page.\n\n| HTTP 429\n| Sleep on `Retry-After` if present, otherwise the parsed `X-Rate-Limit-Reset`, then retry\n  **without incrementing the attempt count**.\n\n| HTTP 5xx\n| Log a truncated body and skip the page. Continue with the next page.\n\n| `ValidationError` on one row\n| Skip that row only, via `map_seller()`.\n\n| `ValidationError` on the response envelope\n| Skip the whole page.\n|===\n\nTwo details carry the design. A 429 does not consume a retry, because rate limiting is not a\nfailure — it is the server telling you to wait, and counting it would exhaust the budget on a\nrequest that was always going to succeed. And a 5xx skips the page rather than aborting the\nrun, so one bad page does not cost the whole invocation's progress.\n\n== SmartScoutProvider\n\n`providers/smartscout/provider.py`.\n\n=== Headers\n\nEvery request, including login, carries the browser fingerprint headers in\n`utils/constants.py` as `SMARTSCOUT_BROWSER_HEADERS`. Search requests add:\n\n[source]\n----\nAuthorization: Bearer <jwt>\nContent-Type:  application/json-patch+json\nRequest-Id:    <fresh uuid4 per request>\n----\n\nThe unusual `application/json-patch+json` content type and the per-request `Request-Id` are\nwhat the API expects; neither is a mistake.\n\n=== Pagination\n\nSmartScout uses the AG Grid infinite row model, so pages are row ranges rather than page\nnumbers:\n\n[source]\n----\nPage 1:  { \"endRow\": 1000 }                    <1>\nPage 2:  { \"startRow\": 1000, \"endRow\": 2000 }\nPage 3:  { \"startRow\": 2000, \"endRow\": 3000 }\n----\n<1> `startRow` is omitted, not sent as `0`.\n\nThe model declares `startRow: int | None = None` and the body is serialised with\n`exclude_none=True`, which is what drops the field on the first page — matching the API's own\nconvention.\n\nThe first response carries `totalRowCount`. Pagination stops when `startRow >= totalRowCount`,\nwhen `max_pages_per_run` is reached, or when a page returns an empty `payload`.\n\n`fetch_sellers()` returns `(records, next_start_row)`, where `0` means the result set was fully\nconsumed and the next run should restart from the beginning. The handler persists that cursor\nas `enrichment.amazon.next_start_row` — see\nxref:data:configs-table.adoc[Runtime state in `configs`].\n\n=== Request body\n\n[source,json]\n----\n{\n  \"loadDefaultData\": false,\n  \"requiresFilter\": false,\n  \"filter\": {\n    \"estimateSalesMin\": 8333.33,\n    \"estimateSalesMax\": 83333.33\n  },\n  \"pageFilter\": {\n    \"endRow\": 1000,\n    \"includeTotalRowCount\": true,\n    \"sortModel\": [],\n    \"fields\": [\n      \"name\", \"amazonSellerId\", \"primaryCategoryId\", \"primarySubCategory\",\n      \"estimateSales\", \"percentFba\", \"numberWinningBrands\", \"numberAsins\",\n      \"numberTopAsins\", \"state\", \"country\", \"businessName\",\n      \"moMGrowth\", \"moMGrowthCount\", \"startedSellingDate\", \"note\"\n    ]\n  }\n}\n----\n\n`estimateSales` is monthly USD. Both bounds are optional — omitting them fetches unfiltered.\nWhen `ICP_CATEGORIES` is set, `primaryCategoryId` is added to `filter`, and only the first\ncategory is sent per request.\n\nThe explicit `fields` list is a cost control: the API returns far more columns than the mapping\nuses, and naming them keeps responses small across thousands of rows.\n\n=== Response\n\n[source,json]\n----\n{\n  \"payload\": [ { \"amazonSellerId\": \"...\", ... } ],\n  \"totalRowCount\": 42800\n}\n----\n\n=== SmartScoutSeller\n\n`models/smartscout.py`. `model_config = ConfigDict(extra=\"allow\")`.\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Field | Type | Notes\n\n| `amazonSellerId` | `str` | The only required field — the primary identifier\n| `name` | `str \\| None` | Display name\n| `businessName` | `str \\| None` | Legal or registered name\n| `primaryCategoryId` | `int \\| None` | An integer id, not a string\n| `primarySubCategory` | `str \\| None` | Human-readable subcategory\n| `estimateSales` | `float \\| None` | Monthly revenue estimate, USD\n| `avgPrice` | `float \\| None` | Average item price\n| `percentFba` | `float \\| None` | FBA proportion\n| `numberWinningBrands` | `int \\| None` | Brands where the seller wins the buy box\n| `numberAsins` | `int \\| None` | Total ASINs\n| `numberTopAsins` | `int \\| None` | High-performing ASINs\n| `state`, `country` | `str \\| None` | Location\n| `moMGrowth` | `float \\| None` | Month-over-month growth\n| `moMGrowthCount` | `int \\| None` | Months of consecutive growth\n| `threeMonthGrowth`, `sixMonthGrowth`, `yearGrowth` | `float \\| None` | Longer growth windows\n| `sixMonthGrowthCount` | `int \\| None` | —\n| `isSuspended` | `bool` | Defaults to `False`\n| `startedSellingDate` | `str \\| None` | ISO datetime string, parsed best-effort\n| `note` | `str \\| None` | Free text\n|===\n\n`extra=\"allow\"` is load-bearing. The SmartScout API is undocumented and changes without\nnotice; strict parsing would turn a new column into a failed page. Unknown fields survive into\n`SellerRecord.raw`, so a field discovered later is already captured in historical rows.\n\n=== Mapping to SellerRecord\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| `SellerRecord` field | Source\n\n| `seller_id` | `amazonSellerId`\n| `marketplace` | Always `Marketplace.AMAZON`\n| `business_name` | `businessName`, then `name`, then `\"\"`\n| `storefront_url` | Built as `https://www.amazon.com/sp?seller=<amazonSellerId>`\n| `estimated_monthly_revenue` | `estimateSales`, default `0.0`\n| `asin_count` | `numberAsins`, default `0`\n| `primary_category` | `primarySubCategory`, then `str(primaryCategoryId)`, then `\"\"`\n| `seller_since` | Parsed from `startedSellingDate` with dateutil; `None` on failure\n| `percent_fba` | `percentFba`\n| `mom_growth` | `moMGrowth`\n| `state`, `country` | Direct\n| `source` | Always `DataSource.SMARTSCOUT`\n| `raw` | Full `model_dump()` of the row\n|===\n\n`SellerRecord` is frozen (`ConfigDict(str_strip_whitespace=True, frozen=True)`) with validators\nenforcing non-negative revenue and ASIN counts. Immutability means a record cannot be mutated\nbetween mapping and publication, so what reaches the queue is what the provider produced.\n\n[#auth]\n== SmartScout authentication\n\n`providers/smartscout/auth.py`. `SmartScoutAuth` owns the whole JWT lifecycle: obtaining,\ncaching in memory, persisting to Secrets Manager, and deciding when to refresh.\n\nThe login endpoint returns an envelope, not a bare token:\n\n[source,json]\n----\n{\n  \"token\": \"eyJhbGci...\",\n  \"refreshToken\": null,\n  \"requireTwoFactor\": false,\n  \"requireMobilePhoneConfirmation\": false\n}\n----\n\n`token` is extracted and decoded with PyJWT — **without signature verification**, since the\nonly claim needed is `exp`.\n\n=== Resolution order\n\n[source]\n----\nget_token()\n  │\n  ├─ in-memory cache valid (>5 min remaining)? ──────────► return\n  │\n  ├─ SMARTSCOUT_TOKEN + SMARTSCOUT_TOKEN_EXPIRES_AT\n  │  in the shared secret still valid? ──────────────────► cache, return\n  │\n  └─ authenticate\n       POST /api/authentication/login with SMARTSCOUT_USERNAME / _PASSWORD\n       decode exp, convert to UTC\n       read-modify-write the shared secret\n       cache, return\n----\n\nThree layers, each avoiding work the next would cost. The in-memory cache serves a warm\ncontainer; the secret serves a *cold* container, which is the case that matters — Lambda\nrecycles containers constantly, and without the shared cache every cold start would\nre-authenticate.\n\nA five-minute buffer treats a token as expired before its actual `exp`, so a token cannot\nlapse mid-page.\n\n[IMPORTANT]\n====\n`_store_session()` is a **read-modify-write**: it reads the full secret JSON, merges only\n`SMARTSCOUT_TOKEN` and `SMARTSCOUT_TOKEN_EXPIRES_AT`, and writes the whole document back.\n\nWriting only the two session keys would destroy every other credential in the shared secret.\nThis is why `enrichment-scraper` is the one function with `secretsmanager:PutSecretValue`, and\nit is the single most dangerous write in the platform — see\nxref:infrastructure:secrets.adoc[Secrets Manager].\n====\n\n`invalidate()` clears the in-memory cache only; the secret is untouched, so the next\n`get_token()` re-evaluates the stored session before authenticating.\n\n=== Forcing a fresh login\n\nDelete the two session keys and the next invocation authenticates:\n\n[source,bash,subs=attributes+]\n----\naws secretsmanager get-secret-value --secret-id {shared-secret} \\\n  --query SecretString --output text \\\n  | jq 'del(.SMARTSCOUT_TOKEN, .SMARTSCOUT_TOKEN_EXPIRES_AT)' \\\n  > /tmp/secret.json\n\naws secretsmanager put-secret-value --secret-id {shared-secret} \\\n  --secret-string file:///tmp/secret.json && rm /tmp/secret.json\n----\n\nOmitting those keys from a manual `put-secret-value` is harmless for the same reason — it costs\none extra authentication.\n\n== Adding a provider\n\nThree steps, and no change to `registry.py`, `handler.py`, `queue.py`, or any existing model.\n\n. **Response model** — `models/<vendor>.py`, with `ConfigDict(extra=\"allow\")`:\n+\n[source,python]\n----\nclass JungleScoutSeller(BaseModel):\n    model_config = ConfigDict(extra=\"allow\")\n    ...\n----\n\n. **Provider** — `providers/<vendor>/provider.py`:\n+\n[source,python]\n----\nclass JungleScoutProvider(BaseProvider[JungleScoutSeller]):\n    def _build_request_headers(self) -> dict[str, str]: ...\n    def _to_seller_record(self, raw: JungleScoutSeller) -> SellerRecord: ...\n    def fetch_sellers(self, filters: SellerFilters,\n                      start_from_row: int = 0) -> tuple[list[SellerRecord], int]: ...\n----\n+\nUse `_post_with_retry()` and `map_seller()` from the base class. Override `_on_unauthorized()`\nif the vendor uses tokens.\n\n. **Call it from a handler** — results compose, because every provider returns the same type:\n+\n[source,python]\n----\nsmartscout_records, next_row = SmartScoutProvider().fetch_sellers(filters, start_row)\njungle_records, _ = JungleScoutProvider().fetch_sellers(filters)\nqueue.publish(smartscout_records + jungle_records)\n----\n\nKeep vendor authentication inside the vendor's own package. `SmartScoutAuth` lives in\n`providers/smartscout/` rather than in a shared `services/` module because its JWT flow is\nproprietary and means nothing to another provider. A shared auth module would accumulate every\nvendor's quirks.\n\nAdd the source's value to `DataSource` in `models/seller.py` — it already carries\n`jungle_scout` and `walmart_scraper` members. See xref:data:enums.adoc[Enumerations].\n\n== Design decisions\n\nWhy retry logic lives in the base class::\nEvery seller API fails the same handful of ways. Centralising them means a new provider gets\nrobust HTTP handling for free, and a fix to backoff behaviour applies everywhere at once.\n\nWhy a registry rather than `if`/`elif`::\nThe dispatcher never learns about a specific handler. Adding an event type is three lines in\n`registry.py` and no change to the entrypoint. See\nxref:handler-pattern.adoc[Handler and registry pattern].\n\nWhy SHA256 for `MessageDeduplicationId`::\nSQS FIFO caps the id at 128 characters. A SHA256 hex digest is exactly 64 and deterministic, so\nthe same seller and marketplace always produce the same id — which is what makes a rerun\nidempotent rather than duplicative. See xref:events:publishing.adoc[Publishing events].\n\nWhy the JWT is stored in Secrets Manager::\nA container is reused for warm invocations but discarded soon after. An in-memory-only cache\nwould re-authenticate on every cold start; the shared secret lets any container inherit a\nvalid token.\n\n== Tests\n\n[source,bash]\n----\ncd lambdas/enrichment_scraper && uv run pytest tests -v\n----\n\n`tests/providers/smartscout/test_provider.py` covers mapping, pagination, and the whole\nxref:#failure-matrix[failure matrix]; `test_auth.py` covers the three-layer token resolution and\ninvalidation. `tests/integration/test_smartscout.py` hits the live API and needs credentials —\nrun it with `uv run pytest -m integration -v`. See xref:testing.adoc[Testing Lambdas].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:enrichment-scraper.adoc","title":"enrichment-scraper","description":"Seller discovery from SmartScout and the incremental BigQuery contact sync.","url":"https://docx.payability.com/morpheus/lambdas/enrichment-scraper.html","navOrder":36,"source":"= enrichment-scraper\n:description: Seller discovery from SmartScout and the incremental BigQuery contact sync.\n\nPhysical name: `{sam-stack}-enrichment-scraper`. Source:\n`lambdas/enrichment_scraper/`.\n\nThe only EventBridge-only function — it has no SQS trigger. It fetches data from\nexternal sources and either publishes it to a queue or writes it to the database.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Handler | `handler.lambda_handler`\n| Timeout | 300 s\n| Memory | 256 MB\n| Layers | common, data-layer, bigquery\n| Reserved concurrency | none\n|===\n\n== Registered events\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Handler | Model | Status\n\n| `SellerDiscovery.Amazon` | `AmazonDiscoveryHandler` | `SellerDiscoveryEvent` | live (rule commented out)\n| `SellerDiscovery.Walmart` | `WalmartDiscoveryHandler` | `SellerDiscoveryEvent` | stub — raises `NotImplementedError`\n| `SellerDiscovery.RunCategory` | `RunCategoryHandler` | `RunCategoryEvent` | stub — raises `NotImplementedError`\n| `Scraper.BigQueryContactSync` | `BigQueryContactSyncHandler` | `ScheduledEvent` | live, `rate(2 hours)`\n|===\n\nOnly `BigQueryContactSync` has an enabled schedule. See\nxref:infrastructure:schedules.adoc[EventBridge schedules].\n\n== `SellerDiscovery.Amazon`\n\nFull business rules: xref:business:seller-discovery.adoc[Seller discovery].\n\n[mermaid]\n....\nflowchart LR\n  A[\"check configs\\nenrichment.amazon.disabled\"] -->|disabled| A1[\"log + return\"]\n  A --> B[\"read next_start_row\"]\n  B --> C[\"SmartScoutProvider.fetch_sellers()\"]\n  C --> D[\"save next_start_row\"]\n  D --> E[\"QueueService.publish()\\nbatches of 10\"]\n....\n\nThe cursor is saved *before* publishing, so a publish failure does not cause the same\npage to be re-fetched forever.\n\nEvent payload — every field defaults, so the EventBridge `Input` can be minimal:\n\n[source,json]\n----\n{\n  \"detail-type\": \"SellerDiscovery.Amazon\",\n  \"source\": \"enrichment.scraper\",\n  \"detail\": { \"marketplace\": \"amazon\", \"provider\": \"smartscout\", \"max_pages\": 5 }\n}\n----\n\n`SellerDiscoveryEvent` is `populate_by_name=True` with `detail_type` aliased to\n`detail-type`, and `id` / `version` / `account` / `region` optional — an `Input`\noverride does not include the standard EventBridge envelope fields.\n\n== `Scraper.BigQueryContactSync`\n\nIncrementally mirrors HubSpot contacts from\n`{bq-project}.{bq-dataset-hubspot}.contacts` into three local tables. This is the main\nsource of `contacts`, `target_emails`, and `target_phone_numbers` rows, and therefore\nof the phone numbers `phone-agent` later classifies.\n\nWatermark: `configs.key = 'bq_contact_sync_watermark'`, default `2026-01-01T00:00:00Z`.\nBatch size: `BIGQUERY_CONTACT_SYNC_BATCH_SIZE`, default 1000.\n\nPer row, best-effort — an exception is logged and the row counted as failed:\n\n. Map the BigQuery row to contact fields (`map_row_to_contact_data`).\n. Look up the existing contact by `hubspotContactId`.\n. If the lifecycle stage is changing on an existing contact, insert an `Activity` NOTE\n  recording the transition *before* updating.\n. Upsert the contact — find-or-create, updating only non-null incoming fields.\n. Ensure a `target_emails` row exists for `property_email`, propagating\n  `property_hs_email_optout` to all three opt-out flags.\n. Ensure a `target_phone_numbers` row exists for `phone` and `mobilePhone`.\n\n=== Unique-constraint guard\n\n`contacts.email` and `contacts.phone` are unique per subscription. When an update would\nmove a value onto a contact that already holds it, the field is silently dropped from\nthe update rather than raising:\n\n[source,python]\n----\nfor field, lookup in ((\"phone\", _contact_repo.get_by_phone),\n                      (\"email\", _contact_repo.get_by_email)):\n    incoming = updates.get(field)\n    if incoming and getattr(existing, field) != incoming:\n        conflict = lookup(session, incoming)\n        if conflict is not None and conflict.id != existing.id:\n            updates.pop(field)\n----\n\n=== Watermark advance and the stall guard\n\nThe new watermark is the maximum `updatedat` across the processed rows, written only\nwhen it advanced. If a *full* batch is returned but the maximum did not advance — a\ncluster of identical timestamps larger than the batch size — the watermark is bumped by\none second and a warning is logged, so the sync cannot loop forever on the same rows.\n\nEach row is processed in its own `get_session()` block, so one failure does not roll\nback the others.\n\n== SmartScout provider\n\n`providers/smartscout/`. Extends the generic `BaseProvider`, which owns retry and\nrate-limit handling; the subclass supplies three hooks:\n\n[TIP]\n====\nThe full provider reference — `BaseProvider`'s interface, the SmartScout request body and\nresponse model, the field-by-field mapping to `SellerRecord`, the token lifecycle, and how to\nadd a second provider — is in xref:provider-pattern.adoc[Provider pattern].\n====\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Hook | Implementation\n\n| `_build_request_headers()` | Browser fingerprint headers, `Authorization: Bearer <token>`, `Content-Type: application/json-patch+json`, a fresh `Request-Id` UUID\n| `_on_unauthorized()` | `self._auth.invalidate()` — clears the cached token so the retry authenticates\n| `_to_seller_record(raw)` | Maps `SmartScoutSeller` → `SellerRecord`\n|===\n\n`_post_with_retry()` handles: timeouts (exponential backoff, then skip the page), 401\n(one retry after invalidating the token), 429 (sleep on `Retry-After` or the parsed\n`X-Rate-Limit-Reset`, then retry without incrementing the attempt count), 5xx (log a\ntruncated body and skip), and proactive throttling when `X-Rate-Limit-Remaining`\nreaches 1 or 0.\n\n`fetch_sellers(filters, start_from_row)` returns `(records, next_start_row)`, where a\nreturned `0` means the result set was fully consumed. `map_seller()` wraps\n`_to_seller_record()` and turns a `ValidationError` into a logged skip, so one bad row\nnever aborts the page.\n\n=== Token caching\n\n`SmartScoutAuth` caches its session token *in the shared Secrets Manager secret*\n(`SMARTSCOUT_TOKEN`, `SMARTSCOUT_TOKEN_EXPIRES_AT`). This is why this function alone\nholds `secretsmanager:PutSecretValue`, and why a manual `put-secret-value` that omits\nthose keys simply forces one extra authentication.\n\n== Configuration\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Setting | Default | Purpose\n\n| `SQS_QUEUE_URL` | (template) | `EnrichmentPipelineQueue`\n| `AGENTCORE_SECRET_ARN` | (template) | Shared secret\n| `SUBSCRIPTION_ID` | (template) | Owner of the `configs` rows this function writes\n| `SMARTSCOUT_BASE_URL` | `https://smartscoutapi-east.azurewebsites.net` |\n| `SMARTSCOUT_MARKETPLACE` | `US` | Marketplace header\n| `ICP_REVENUE_MIN` | `100000` | Annual, divided by 12 for the API\n| `ICP_REVENUE_MAX` | `1000000` | Annual, divided by 12\n| `ICP_CATEGORIES` | `[]` | Comma-separated; a `field_validator` splits the string\n| `MAX_PAGES_PER_RUN` | `1` | Overridable per event\n| `PAGE_SIZE` | `10` |\n| `BIGQUERY_CONTACT_SYNC_BATCH_SIZE` | `1000` |\n| `LOG_LEVEL` | `INFO` |\n|===\n\nA `model_validator` rejects `ICP_REVENUE_MIN >= ICP_REVENUE_MAX` at import, so a bad\nvalue fails the cold start.\n\nSecrets read (`ScraperSecrets`): `DATABASE_URL`, `SMARTSCOUT_USERNAME`,\n`SMARTSCOUT_PASSWORD` (all validated non-empty), plus optional `SMARTSCOUT_TOKEN`,\n`SMARTSCOUT_TOKEN_EXPIRES_AT`, and `BIGQUERY_CREDENTIALS`.\n\n== IAM\n\n* `SQSSendMessagePolicy` on `EnrichmentPipelineQueue`\n* `secretsmanager:GetSecretValue` *and* `PutSecretValue` on `AgentcoreSecret`\n\nNo Bedrock access — this function runs no agents.\n\n== Tests\n\n[source,bash]\n----\ncd lambdas/enrichment_scraper && uv run pytest tests -v\n----\n\n`tests/providers/smartscout/test_provider.py` (456 lines) and `test_auth.py` (336\nlines) cover mapping, pagination, retry, and token caching.\n`tests/integration/test_smartscout.py` hits the real API and needs credentials.\n`lambdas/tests/unit/test_bigquery_contact_sync.py` covers the contact sync.\n\n== Invoking locally\n\n[source,bash]\n----\ncd lambdas\nsam local invoke EnrichmentScraperFunction \\\n  --event events/event.json \\\n  --env-vars local-env.json\n----\n\n`local-env.json` is not checked in — create it, keyed by the function's logical id:\n\n[source,json]\n----\n{\n  \"EnrichmentScraperFunction\": {\n    \"SQS_QUEUE_URL\": \"https://sqs.us-east-1.amazonaws.com/625663093496/morpheus-sam-enrichment-pipeline.fifo\",\n    \"AGENTCORE_SECRET_ARN\": \"arn:aws:secretsmanager:us-east-1:625663093496:secret:agentcore-configs-XXXXX\",\n    \"LOG_LEVEL\": \"DEBUG\"\n  }\n}\n----\n\nThe invocation uses your local AWS credentials, so it publishes to the **real** queue and reads\nthe **real** secret. Point `SQS_QUEUE_URL` at a scratch queue unless you intend to feed the\npipeline.\n\nTwo event payloads worth keeping around, both of which exercise routing rather than discovery:\n\n[source,json]\n----\n{ \"detail-type\": \"SellerDiscovery.RunCategory\", \"source\": \"enrichment.scraper\",\n  \"detail\": { \"category\": \"health-beauty\", \"marketplace\": \"amazon\" } }\n----\n\nroutes through the dispatcher and raises `NotImplementedError` — the expected stub behaviour.\nAn unknown `detail-type` returns successfully with a warning and no error, which is how the\nfunction tolerates a bus that routes it events it does not own.\n\n== Monitoring\n\nLog output is structured JSON. `handler.py` injects `lambda_request_id` and `event_type` at\nstartup and the `_JsonFormatter` in `utils/logging.py` carries them into every subsequent\nrecord, so a single invocation can be reconstructed from any log line:\n\n[source,json]\n----\n{\n  \"timestamp\": \"2026-03-31T07:00:01.234Z\",\n  \"level\": \"INFO\",\n  \"lambda_request_id\": \"abc-123-def\",\n  \"event_type\": \"SellerDiscovery.Amazon\",\n  \"logger\": \"enrichment_scraper.providers.smartscout.provider\",\n  \"message\": \"Fetched 847 valid seller records total\"\n}\n----\n\nCloudWatch Logs Insights:\n\n[source]\n----\nfields timestamp, level, message, logger\n| filter level = \"ERROR\"\n| sort timestamp desc\n\nfields timestamp, message, lambda_request_id\n| filter message like \"Published\"\n| sort timestamp desc\n\nfields timestamp, message\n| filter message like \"Rate limit\"\n| sort timestamp desc\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Signal | What it means\n\n| `Duration` P99 approaching 300 s\n| Pagination is too aggressive for the timeout — lower `MAX_PAGES_PER_RUN`.\n\n| Frequent `Rate limited (429)`\n| The SmartScout window is too narrow for the current page count. The provider is handling it,\n  but throughput is being spent on waiting.\n\n| `Skipping seller` errors\n| The API response shape changed. Check `SmartScoutSeller` — see\n  xref:provider-pattern.adoc[Provider pattern].\n\n| `No handler registered` warnings\n| Something is routing unexpected events at this function.\n|===\n\n== Operating notes\n\n* Discovery is off by default today — both `SellerDiscovery.*` rules are commented out.\n  Invoke manually; see xref:business:seller-discovery.adoc#current-schedule-state[current schedule state].\n* The downstream SQS trigger on `enrichment-scorer` is also commented out, so published\n  messages accumulate on the queue.\n* `enrichment.amazon.disabled` in `configs` is the kill switch and is checked before\n  any network call.\n* Empty `BIGQUERY_CREDENTIALS` makes the contact sync log a warning and return.\n* To force a fresh SmartScout login, delete the two session keys from the shared secret — see\n  xref:provider-pattern.adoc#auth[SmartScout authentication].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:enrichment-scorer.adoc","title":"enrichment-scorer","description":"Seller persistence, AI enrichment, payout-pain scoring, and CRM publication.","url":"https://docx.payability.com/morpheus/lambdas/enrichment-scorer.html","navOrder":37,"source":"= enrichment-scorer\n:description: Seller persistence, AI enrichment, payout-pain scoring, and CRM publication.\n\nPhysical name: `{sam-stack}-enrichment-scorer`. Source:\n`lambdas/enrichment_scorer/`.\n\nConsumes raw seller records, persists them, runs two AI passes over them, and publishes\nthe result to the CRM queue. The only function whose behaviour is genuinely\nprobabilistic — which is why every AI step is best-effort and every database write\ncommits before one runs.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Handler | `handler.lambda_handler`\n| Timeout | 120 s (queue `VisibilityTimeout` is 180 s)\n| Memory | 512 MB\n| Reserved concurrency | 5\n| Layers | common, data-layer, llm, pyb-hubspot\n|===\n\n== Registered events\n\nOne registry serves both transports.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| Routing key | Handler | Model | Trigger\n\n| `SellerRecord.Received` | `SellerReceivedHandler` | `SellerRecord` | `EnrichmentPipelineQueue` (trigger commented out)\n| `Seller.EnrichPending` | `EnrichPendingSellersHandler` | `ScheduledEvent` | EventBridge (rule commented out)\n| `Seller.ScorePending` | `ScorePendingSellersHandler` | `ScheduledEvent` | EventBridge (rule commented out)\n|===\n\n[NOTE]\n====\nAll three triggers are currently commented out in `lambdas/template.yaml`. The function\ndeploys and its handlers work when invoked directly, but nothing drives it\nautomatically. See xref:infrastructure:schedules.adoc[EventBridge schedules].\n====\n\nThe SQS path defaults its routing key to `SellerRecord.Received` when the `event_type`\nmessage attribute is absent, for compatibility with messages published before the\nscraper set it.\n\n== `BaseEnrichmentHandler`\n\n`events/base_enrichment.py` — the one base class in the repository that carries real\nbehaviour rather than just an abstract method. Subclasses differ only in how they\n*source* sellers and in what order they call the inherited steps.\n\n[source]\n----\nBaseEventHandler                          (events/base.py)\n  └── BaseEnrichmentHandler               (events/base_enrichment.py)\n        │  _seller_repo, _contact_repo    (class attributes)\n        │  _enrich(...)                   AI pass 1 — Tavily + HubSpot MCP\n        │  _apply_enrichment(...)         write seller + contacts, publish CRM event\n        │  _score(...)                    AI pass 2 — no tools\n        │  _apply_score(...)              write payoutPainScore\n        │  _build_crm_sync_event(...)     snapshot ORM state into a fat event\n        │  _publish_crm_sync(...)         fire-and-forget SQS publish\n        ├── SellerReceivedHandler         SQS — adds _upsert_seller()\n        ├── EnrichPendingSellersHandler   schedule — queries pending sellers\n        └── ScorePendingSellersHandler    schedule — scoring only\n----\n\nRepositories are *class attributes*, instantiated once at import. Repositories are\nstateless and never open sessions, so sharing them is safe.\n\n== `SellerRecord.Received`\n\nFull rules: xref:business:seller-enrichment.adoc[Seller enrichment].\n\n[source,python]\n----\nseller = self._upsert_seller(event)            # step 1 — COMMITS\n\nif seller.enrichedAt is None:                  # steps 2 + 3, best-effort\n    try:\n        enrichment = self._enrich(...)\n        self._apply_enrichment(event.seller_id, enrichment)\n    except Exception:\n        logger.error(..., exc_info=True)\n\nif seller.payoutPainScore is None:             # step 4, best-effort\n    try:\n        score = self._score(...)\n        self._apply_score(event.seller_id, score)\n    except Exception:\n        logger.error(..., exc_info=True)\n----\n\n[IMPORTANT]\n====\nSteps 2–4 must never cause an SQS retry. The database write in step 1 has already\ncommitted; re-delivering the message would repeat the AI cost for no benefit. Catch\neverything, log at `ERROR`, return.\n====\n\n`_upsert_seller()` also handles two data-shape concerns: `estimateSales`,\n`numberAsins`, and `numBrands` are clamped to `INT32_MAX` (PostgreSQL `INTEGER` is\n32-bit signed and outliers such as Amazon Retail exceed it), and `PLATFORM_MAP` /\n`DATASOURCE_MAP` translate the scraper's enums into the database's.\n\nAfter the upsert the row is refreshed inside the session and `enrichedAt`,\n`payoutPainScore`, and `note` are touched, so they remain readable on the detached\nobject once the session closes.\n\n== `Seller.EnrichPending`\n\nBackfill for sellers that missed the SQS path. Selects up to 10 per run\n(`get_pending_enrichment()`: `lifeCycleStage = new` and `enrichedAt IS NULL`) and runs\nthe same four steps per seller, each in its own `try`/`except`. One seller's failure\nlogs and the loop continues.\n\nNote the fallback: when enrichment fails, scoring still runs using `seller.note` as its\nenrichment context instead of the fresh result.\n\n== `Seller.ScorePending`\n\nScoring only. Selects up to 10 enriched sellers with no score\n(`get_pending_scoring()`: `enrichedAt IS NOT NULL` and `payoutPainScore IS NULL`) and\nruns `_score()` → `_apply_score()`. Intended for backfilling after the scoring feature\nwas introduced, or after a deliberate rescore.\n\n== The two agents\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| | Enrichment agent | Scoring agent\n\n| Module | `agents/seller_enrichment_agent.py` | `agents/seller_scoring_agent.py`\n| Model | `CLAUDE_HAIKU_4_5` | `CLAUDE_HAIKU_3`\n| `max_tokens` | 2048 | 4096\n| `max_turns` | 6 | 3\n| Tools | Tavily MCP + HubSpot MCP (factory) | none\n| Output model | `SellerEnrichmentResult` | `PayoutPainScoreResult`\n|===\n\nBoth are built inside `@lru_cache(maxsize=1)` functions with *deferred imports* of\n`morpheus_llm`, so the module can be imported in a test environment without the Lambda\nlayer installed:\n\n[source,python]\n----\n@lru_cache(maxsize=1)\ndef _get_agent():\n    from morpheus_llm.providers import ClaudeAgentService\n    from morpheus_llm.tools import create_hubspot_mcp, create_tavily_mcp\n    from morpheus_llm.types import AgentConfig\n    from morpheus_llm.utils import AgentModelIds\n    return ClaudeAgentService(config=AgentConfig(...), tools=lambda: [...])\n----\n\nPreserve that pattern when adding an agent — it is what keeps the tests runnable.\n\nPrompts are in `agents/prompts.py`. The scoring prompt contains the entire 8-signal\nformula; see xref:business:payout-pain-score.adoc[Payout-pain score].\n\nToken usage — including cache read and write — is logged per seller for both agents.\n\n== CRM publication\n\nWhen `CRM_SYNC_QUEUE_URL` is set, `_build_crm_sync_event()` assembles a fat\n`SellerSyncEvent` from the in-session ORM objects (it *must* run before the session\ncloses; detached objects cannot lazy-load) and `_publish_crm_sync()` sends it after the\ncommit:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| SQS field | Value\n\n| `MessageGroupId` | `seller_id`\n| `MessageDeduplicationId` | `sha256(\"crm-sync-\" + seller_id)`\n|===\n\nFire-and-forget: a publish failure logs at `WARNING` and the committed enrichment\nstands. An empty `CRM_SYNC_QUEUE_URL` skips event construction entirely.\n\n== Configuration\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Setting | Default | Purpose\n\n| `AGENTCORE_SECRET_ARN` | (template) | Shared secret\n| `SUBSCRIPTION_ID` | (template) | Must match the scraper's, so sellers are owned by the same tenant\n| `CRM_SYNC_QUEUE_URL` | `\"\"` | Optional — the function still writes to the database without it\n| `LOG_LEVEL` | `INFO` |\n|===\n\n`ScorerSecrets` reads `DATABASE_URL`, `TAVILY_API_KEY`, and the HubSpot OAuth triple.\n\n== IAM\n\n* `SQSPollerPolicy` on `EnrichmentPipelineQueue`\n* `SQSSendMessagePolicy` on `CrmSyncQueue`\n* `secretsmanager:GetSecretValue` on `AgentcoreSecret`\n* `bedrock:InvokeModel` and `InvokeModelWithResponseStream` on `*`\n\n== Cost control\n\n`ReservedConcurrentExecutions: 5`, `BatchSize: 1`, `max_turns` caps on both agents, and\nAnthropic prompt/tool caching enabled by `ClaudeAgentService`. The\n`BedrockTokenAlarm` fires above 5M input tokens per hour.\n\nThe commented-out SQS trigger also carries `ScalingConfig.MaximumConcurrency: 50` with\na note explaining the constraint: each invocation holds a database connection for the\nbatch, and the data layer's pool default is 5 per process. Raise it once RDS Proxy sits\nin front of the database.\n\n== Operating notes\n\n* Both AI steps skip cleanly when `enrichedAt` / `payoutPainScore` is already set, so\n  re-running is cheap.\n* To rescore a cohort, null `payoutPainScore` for those sellers and drive\n  `Seller.ScorePending` — 10 per invocation, or raise `_BATCH_SIZE` in the handler.\n* A seller stuck at `lifeCycleStage = new` with a null `enrichedAt` means enrichment\n  has failed repeatedly. Check the function's logs for the `Enrichment pipeline failed`\n  line.\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:morpheus-hubspot.adoc","title":"morpheus-hubspot","description":"The single writer to HubSpot — 12 SQS event types, 9 scheduled handlers.","url":"https://docx.payability.com/morpheus/lambdas/morpheus-hubspot.html","navOrder":38,"source":"= morpheus-hubspot\n:description: The single writer to HubSpot — 12 SQS event types, 9 scheduled handlers.\n\nPhysical name: `{sam-stack}-hubspot-sync`. Source: `lambdas/morpheus_hubspot/`.\n\nThe largest and busiest function in the platform. It is the *only* component that\nwrites to HubSpot, which concentrates OAuth, property mapping, rate-limit exposure, and\nre-engagement segmentation in one place.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Handler | `handler.lambda_handler`\n| Timeout | 300 s (`CrmSyncQueue` `VisibilityTimeout` is 360 s)\n| Memory | 256 MB\n| Layers | common, data-layer, pyb-hubspot, bigquery\n| SQS concurrency | `MaximumConcurrency: 10`, `BatchSize: 1`\n|===\n\n== SQS events — `event_registry`\n\nRouting key comes from the `event_type` field *inside the message body*, not from a\nmessage attribute. A body may also be a JSON *array* of event objects, each dispatched\nindividually.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| `event_type` | Handler | Purpose\n\n| `Seller.CrmSync` | `SellerCrmSyncHandler` | Upsert a Company + its Contacts, log Notes on updates, write ids back\n| `Contact.Update` | `ContactUpdateHandler` | Generic Contact PATCH by id; optional `target_phone_numbers` write-back\n| `Contact.Unsubscribe` | `ContactUnsubscribeHandler` | Communication Preferences (email subscription types)\n| `Contact.ResolveHubSpotIds` | `ContactResolveHubSpotIdsHandler` | Backfill `hubspotContactId` from CRM search\n| `Crm.NewActivity` | `CrmNewActivityHandler` | Create a logged Email object, plus an incoming-email follow-up Task and an AgentEmail event\n| `Crm.UpdateActivity` | `CrmUpdateActivityHandler` | PATCH an existing Email object\n| `Crm.CustomEvent` | `CrmCustomEventHandler` | Send a whitelisted behavioural event\n| `Crm.TaskCreate` | `CrmTaskCreateHandler` | Create a HubSpot Task and mirror it in `follow_up_tasks`\n| `Crm.DealUpdate` | `CrmDealUpdateHandler` | Set a Deal stage, by deal id or across a contact's open deals\n| `Crm.DialerCallEnded` | `CrmDialerCallEndedHandler` | AI dialer call sync — the densest handler in the repository\n| `Crm.HubSpotWebhook` | `CrmHubSpotWebhookHandler` | Dispatch inner HubSpot webhook events to processors\n| `FollowUpTask.Sync` | `FollowUpTaskSyncHandler` | Sync a task's status to HubSpot, or accept HubSpot's completed state\n|===\n\nPayload schemas: xref:events:crm-sync-queue.adoc[CrmSyncQueue events]. Business rules:\nxref:business:crm-sync.adoc[CRM sync].\n\nAn unknown `event_type` is logged at `ERROR` and the message *discarded* (the dispatcher\nreturns `True`), since no retry could route it.\n\n== Scheduled events — `scheduled_registry`\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Handler | Purpose\n\n| `Hubspot.FollowUpTaskPoll` | `FollowUpTaskHubSpotPollHandler` | Poll HubSpot for completed tasks\n| `Hubspot.ContactResolve` | `ContactHubSpotResolveHandler` | Resolve missing `hubspotContactId` with a wide search\n| `Hubspot.BigQueryEngagementSync` | `BigQueryEngagementSyncHandler` | Mirror HubSpot engagements into `Activity`\n| `Hubspot.EmailClickReEngagement` | `EmailClickReEngagementHandler` | Email-click re-engagement segment\n| `Hubspot.SupplierAccountReEngagement` | `SupplierAccountReEngagementHandler` | Supplier account-created segment\n| `Hubspot.UnattendedMeetingReEngagement` | `UnattendedMeetingReEngagementHandler` | Meeting no-show segment\n| `Hubspot.LostDealReEngagement` | `LostDealReEngagementHandler` | Lost/stalled deal segment\n| `Hubspot.SalesRepCallListRefresh` | `SalesRepCallListRefreshHandler` | Rebuild each rep's dynamic HubSpot call list\n| `Hubspot.Five9CallSync` | `Five9CallSyncHandler` | Import Five9 CSV call logs from Files.com\n|===\n\nAll nine rules are enabled. Cadences are in\nxref:infrastructure:schedules.adoc[EventBridge schedules]; several are set to\ndevelopment values rather than what their descriptions claim.\n\n== Services\n\nHandlers stay thin; orchestration lives in `services/`.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Module | Lines | Responsibility\n\n| `dialer_call_sync.py` | 688 | The whole `Crm.DialerCallEnded` pipeline: skip rules, owner selection, contact resolution, engagement creation, follow-up task, Nooks enrolment\n| `contact_service.py` | 121 | `ensure_local_contact_row()` — guarantee a local `contacts` row, backfilling from the CRM and repairing target foreign keys\n| `lifecycle_stage.py` | 101 | `maybe_promote_lifecycle_stage()` — rank-guarded promotion to MQL, best-effort\n| `deal_update.py` | 65 | Resolve deal ids from a `Crm.DealUpdate` event and update each stage\n|===\n\nBusiness detail: xref:business:call-logging.adoc[Call logging and dispositions].\n\n== Webhook processors\n\n`Crm.HubSpotWebhook` carries a *batch* of inner HubSpot notifications. The handler opens\none session and one CRM client for the whole batch and dispatches each inner event\nthrough `WebhookProcessorRegistry`. Processors are *ordered*: the first whose\n`applies_to()` returns `True` handles that event exclusively.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Processor | Matches\n\n| `NewsletterOptOutProcessor` | `contact.propertyChange` where `property_name` is `hs_email_optout_<id>` and the id equals `44467216`\n|===\n\nProcessor contract:\n\n* `applies_to(inner) -> bool` — match tightly on `subscription_type` and\n  `property_name`.\n* `process(*, session, client, subscription_id, inner)` — read from HubSpot with\n  `client`, persist with `session` and the data-layer repositories.\n* *Do not commit inside a processor* — the handler's `get_session()` block commits once\n  per message.\n* Never log full payloads or raw PII.\n* An unsupported inner shape must log and return, not raise, so SQS does not retry the\n  whole batch for an unknown notification type.\n\nRegistering: append an instance to the default tuple in\n`WebhookProcessorRegistry.__init__`, putting more specific matchers before generic ones.\nSee xref:events:hubspot-webhooks.adoc[HubSpot webhooks].\n\n== HubSpot client\n\n`utils/hubspot_client.py` builds a `HubSpotSyncClient` from `HubspotSecrets`, preferring\nthe Private App token and falling back to the OAuth triple. `HubspotSecrets` validates\nat load time that at least one complete auth mode exists and raises otherwise, so a\nmisconfigured secret fails the cold start rather than the first API call.\n\nEvery HubSpot API call goes through xref:libs:pyb-hubspot.adoc[`pyb-hubspot`]. Handlers\nnever import from `hubspot.*` directly.\n\n== Configuration\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Setting | Default | Purpose\n\n| `AGENTCORE_SECRET_ARN` | (template) | Shared secret\n| `SUBSCRIPTION_ID` | (template) | Events with a different `subscription_id` are rejected\n| `CONTACT_HUBSPOT_RESOLVE_BATCH_SIZE` | `25` in template (code `100`) | Rows per `Contact.ResolveHubSpotIds` run\n| `HUBSPOT_TASK_POLL_BATCH_SIZE` | `100` | Tasks per poll run\n| `BIGQUERY_ENGAGEMENT_SYNC_BATCH_SIZE` | `1000` | Engagement rows per sync\n| `FIVE9_CALL_SYNC_BATCH_SIZE` | `500` | Five9 rows per sync\n| `LOG_LEVEL` | `INFO` |\n|===\n\n`HubspotSecrets` reads `DATABASE_URL`, `HUBSPOT_PRIVATE_APP_TOKEN`, the OAuth triple,\n`HUBSPOT_ENVIRONMENT`, the three Nooks keys, `BIGQUERY_CREDENTIALS`, and\n`FILES_COM_API_KEY`.\n\nNon-environment constants live in `utils/constants.py`: the newsletter subscription\ntype id (`44467216`), the AI fallback owner id (`86817215`), all four BigQuery\nre-engagement queries, watermark keys and defaults, the Five9 disposition→GUID map, the\n`outreach_list_reason` strings, and the call-list name template.\n\n[#portal-setup]\n== HubSpot portal setup\n\nThe function authenticates as a HubSpot Private App (or OAuth app). Permissions are granted in\nthe portal, not by IAM, so a missing scope surfaces as a 403 at runtime rather than at deploy.\n\nMinimum scopes:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Area | Scopes\n\n| CRM objects\n| Companies and Contacts read/write — the core sync\n\n| Emails\n| `crm.objects.emails` — create and update logged emails\n\n| Behavioral events\n| Send custom event occurrences, for `Crm.CustomEvent`\n\n| Communication Preferences\n| `subscriptions-definition-read`, `subscriptions-status-read`,\n  `subscriptions-status-write` — required by `Contact.Unsubscribe`\n|===\n\nEach `CustomEventKind` also needs a matching custom behavioral event defined in the portal\nbefore `POST /events/v3/send` will accept it.\n\n[#subscription-types]\n=== Email subscription type ids\n\n`Contact.Unsubscribe` sends a `hubspot_subscription_type_id` per update. These ids are\n**portal-specific**. Snapshot for portal `{hubspot-portal}`, default brand\n(`businessUnitId: 0`):\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Id | Name | Notes\n\n| `11599822` | Marketing Information | Marketing offers and updates; the default subscription type\n| `44467216` | Newsletter | Weekly email newsletter\n| `31174826` | One to One | HubSpot internal default (`isInternal: true`)\n|===\n\n`44467216` is the only one hardcoded, as `HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID` in\n`utils/constants.py`. The webhook processor matches `hs_email_optout_44467216` against it to\ndetect a newsletter opt-out. It is a code constant rather than an environment variable, so\nchanging portals means changing code.\n\nRe-fetch the current list rather than trusting this table:\n\n[source,bash]\n----\ncurl -H \"Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN\" \\\n  https://api.hubapi.com/communication-preferences/2026-03/definitions\n----\n\n`results[].id` values are what to send. Deleting and recreating a subscription type in HubSpot\nissues a **new** id, which silently breaks any producer still sending the old one — the API\naccepts the call and updates nothing. Confirm under *Settings → Marketing → Email →\nSubscription types* before wiring a new producer.\n\n[NOTE]\n====\nThe Communication Preferences API is email-oriented. Non-email channels are not fully\nrepresented, which is why `pyb_hubspot` always sends `channel: EMAIL`. See\nxref:libs:pyb-hubspot.adoc[pyb-hubspot].\n====\n\n== IAM\n\n* `SQSPollerPolicy` on `CrmSyncQueue`\n* `secretsmanager:GetSecretValue` on `AgentcoreSecret`\n\nNo Bedrock access — this function runs no agents.\n\n== Tests\n\n[source,bash]\n----\ncd lambdas && uv run pytest morpheus_hubspot/tests -v\n----\n\nCovers the four re-engagement segments, dialer filters, Five9 sync, lifecycle\npromotion, `ensure_local_contact_row`, the newsletter opt-out processor, the sales-rep\ncall-list refresh, and BigQuery engagement sync.\n\n== Operating notes\n\n* Everything routes through one function, so a HubSpot outage backs up\n  `CrmSyncQueue` and then fills `CrmSyncDlq` (`maxReceiveCount: 1`).\n* Empty `BIGQUERY_CREDENTIALS` disables four scheduled handlers at once — the three\n  re-engagement segments and the engagement sync — each logging a warning.\n* Missing `FILES_COM_API_KEY` disables the Five9 sync.\n* Watermarks for the engagement and Five9 syncs live in `configs`. Rewinding the Five9\n  watermark re-creates HubSpot call engagements — see\n  xref:ROOT:operations.adoc#watermarks-and-cursors[watermarks].\n* The dispatcher's \"Handler failed\" `ERROR` line includes the full message body, so this\n  log group can contain PII after a failure.\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:email-agent.adoc","title":"email-agent","description":"Inbound email reply categorisation with Bedrock structured output, and the multi-tenant safety model.","url":"https://docx.payability.com/morpheus/lambdas/email-agent.html","navOrder":39,"source":"= email-agent\n:description: Inbound email reply categorisation with Bedrock structured output, and the multi-tenant safety model.\n\nPhysical name: `{sam-stack}-email-agent`. Source: `lambdas/email_agent/`.\n\nConsumes inbound email events from `EmailAgentQueue`. Its live capability is\nclassifying reply intent; the raw Resend inbound handler is a logging stub.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Handler | `handler.lambda_handler`\n| Timeout | 120 s (queue `VisibilityTimeout` is 180 s)\n| Memory | 512 MB\n| Reserved concurrency | 5\n| SQS concurrency | `MaximumConcurrency: 5`, `BatchSize: 1`\n| Layers | common, data-layer, llm\n|===\n\n== Registered events\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `event_type` | Handler | Model | Status\n\n| `Email.IncomingCategorise` | `IncomingEmailCategoriseHandler` | `IncomingCategoriseEvent` | live\n| `Resend.InboundEmail` | `InboundEmailHandler` | `ResendEmailReceivedEvent` | logging stub\n|===\n\nThe routing key comes from the `event_type` SQS *message attribute*. A record without\nit logs a warning and is skipped.\n\n[NOTE]\n====\n`phone-agent` publishes `Email.SendInternal` to this queue during stage-1 task\nescalation, but no handler is registered for it. The dispatcher logs \"No handler\nregistered for SQS event_type\" and deletes the message. See\nxref:business:follow-up-tasks.adoc#escalation[escalation ladder].\n====\n\n== `Email.IncomingCategorise`\n\nFull rules: xref:business:email-engagement.adoc[Email engagement].\n\n=== Payload\n\n[source,python]\n----\nclass IncomingCategoriseEvent(BaseModel):\n    event_type: Literal[\"Email.IncomingCategorise\"]\n    campaign_id: str                       # required\n\n    contact_email: str | None              # resolve target via (campaignId, email)\n    email_campaign_target_id: str | None   # or resolve by primary key\n\n    inbound_subject: str = \"\"\n    inbound_text: str                      # required, min_length=1\n\n    variation_id: str | None               # override; normally read from the target row\n    email_activity_id: str | None          # optional id of an already-persisted CRM email\n    email_id: str | None                   # optional emails row to update alongside\n----\n\n`contact_email` and `email_campaign_target_id` are mutually exclusive resolution paths;\nexactly one is expected.\n\n=== Two sessions, one LLM call between them\n\n[source,python]\n----\nwith get_session() as session:                       # session 1\n    target = self._resolve_campaign_target(...)      # load + campaign check\n    sub_id = target.subscriptionId                   # tenant scope from the row\n    self._verify_contact_aligns_with_target(...)\n    outbound = self._resolve_outbound_prompt_context(...)\n    target_id = target.id\n\nllm = run_reply_categorisation(...)                  # NO session held\n\nself._persist_categorisation(...)                    # session 2: re-fetch, re-check, write\n----\n\nThe LLM call runs outside any session so a database connection is not held open across\nBedrock latency. The target row is re-fetched afterwards and the tenant re-checked,\nbecause it could have changed during the call.\n\n=== Outbound context\n\nTo judge a reply, the model needs to see what was sent:\n\n. `variation_id` from the event, if provided.\n. Otherwise `EmailCampaignTarget.variationId`.\n. Load that `EmailVariation`; use its `subject`, and its `htmlBody` converted by\n  `html_to_plain_text()`.\n\nA missing or unresolvable variation is a *warning* — the model classifies with empty\noutbound context. Only a variation whose `subscriptionId` mismatches is fatal.\n\n=== Categorisation agent\n\n`agents/reply_categorisation.py`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Model | `AgentModelIds.NOVA_LITE`\n| `max_tokens` | 1024\n| `max_turns` | 3\n| Tools | none — `tools=lambda: []`\n| Structured output | `EmailReplyCategorisationResult` — `category` (enum) + optional `reasoning`\n|===\n\nBuilt inside `@lru_cache(maxsize=1)` with deferred `morpheus_llm` imports, so the module\nis importable without the Lambda layer.\n\n`run_reply_categorisation()` raises `RuntimeError` when `structured_output` is `None`,\nso a model that fails to call the output tool becomes a retryable failure rather than a\nsilent no-op.\n\nCategories: `interested`, `not_interested`, `question`, `out_of_office`, `unsubscribe`,\n`wrong_person`, `other`.\n\n=== Persisted result\n\nOn the `email_campaign_targets` row: `category`, `categorisedAt`, `categoryModel` (the\nmodel id). When `email_id` is set, that `emails` row's `category` is set in the same\ntransaction.\n\n[#tenancy]\n== Tenant safety\n\nThis function deliberately has *no* `SUBSCRIPTION_ID` environment variable — the queue\nis multi-tenant by design. Tenant scope is derived from data:\n\n. Load `EmailCampaignTarget`; `row.subscriptionId` becomes the scope.\n. Verify the `Contact` (when present) has the same `subscriptionId`.\n. Verify the `EmailVariation` (when present) has the same `subscriptionId`.\n. Verify the campaign id matches, when resolving by primary key.\n. After the LLM call, re-fetch the target and verify the subscription again.\n\nEvery mismatch logs at `ERROR` and the handler *returns without raising*, so the message\nis deleted. A bad producer cannot poison-retry indefinitely.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Failure | Outcome\n\n| Malformed `campaign_id` or other UUID | log, return (message deleted)\n| Target not found | log, return\n| Campaign mismatch on the target row | log, return\n| Contact or variation subscription mismatch | log, return\n| Target disappeared during the LLM call | log, return\n| `emails` row named by `email_id` not found | log, return\n| LLM error, or any unexpected exception | propagate → `batchItemFailures` → DLQ\n|===\n\n== `Resend.InboundEmail`\n\n`InboundEmailHandler` logs the email id, sender, subject, and attachment count, and does\nnothing else. Its module docstring lists the intended behaviour: classify reply intent,\nmatch to the outbound sequence via AgentCore Memory using the `in-reply-to` message id,\nupdate the HubSpot lifecycle stage, invoke the AgentCore runtime for complex replies,\nand add bounce/unsubscribe suppression entries.\n\nThe event model (`ResendEmailReceivedEvent`) is complete — typed envelope, data payload,\nattachments — so only the handler body is missing.\n\n== Prompt injection\n\n`inbound_text` is verbatim sender content, interpolated with no sanitisation. The\ncontrols are structured output (`category` constrained to the enum) and an empty tool\nlist. Residual risk is bounded *misclassification*, not code execution. Documented in\n`agents/prompts.py` and in xref:ROOT:security.adoc#prompt-injection[prompt injection].\n\n== Configuration\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Setting | Default | Purpose\n\n| `AGENTCORE_SECRET_ARN` | (template) | Shared secret\n| `LOG_LEVEL` | `INFO` |\n|===\n\nSecrets read: `DATABASE_URL`, plus whatever `EmailAgentSecrets` declares. No\n`SUBSCRIPTION_ID` — see xref:#tenancy[tenant safety].\n\n== IAM\n\n* `SQSPollerPolicy` on `EmailAgentQueue`\n* `secretsmanager:GetSecretValue` on `AgentcoreSecret`\n* `bedrock:InvokeModel` and `InvokeModelWithResponseStream` on `*`\n\n== Documentation in the source tree\n\n* `lambdas/email_agent/CLAUDE.md` — function-specific guidance for AI coding agents\n* `lambdas/email_agent/events/samples/README.md` — when to use each sample payload\n* Samples: `email_incoming_categorise_by_email.json`,\n  `email_incoming_categorise_by_target_id.json`\n\n== Tests\n\n[source,bash]\n----\ncd lambdas && uv run pytest tests/unit/test_email_agent_incoming_categorise.py -v\n----\n\n== Operating notes\n\n* The queue and its outputs are commented out in the SAM template's `Outputs` section,\n  so the queue URL must be read from the console or from\n  `aws sqs get-queue-url --queue-name {sam-stack}-email-agent`.\n* `EmailAgentQueue` is a *standard* queue, not FIFO — no message groups, no\n  content-based deduplication.\n* Nova rejects Anthropic cache points, which `ClaudeAgentService` handles by disabling\n  them for non-Anthropic models. See xref:libs:morpheus-llm.adoc#caching[prompt caching].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:phone-agent.adoc","title":"phone-agent","description":"Twilio line intelligence, Slack task reminders and escalation, and campaign re-engagement.","url":"https://docx.payability.com/morpheus/lambdas/phone-agent.html","navOrder":40,"source":"= phone-agent\n:description: Twilio line intelligence, Slack task reminders and escalation, and campaign re-engagement.\n\nPhysical name: `{sam-stack}-phone-agent`. Source: `lambdas/phone_agent/`.\n\nThree unrelated scheduled jobs share this function because they share its integrations:\nTwilio, Slack, and the two outbound queues.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Aspect | Value\n\n| Handler | `handler.lambda_handler`\n| Timeout | 120 s\n| Memory | 256 MB\n| Reserved concurrency | 2\n| Layers | common, data-layer, bigquery\n| Triggers | 3 EventBridge schedules, all enabled\n|===\n\nIt has no SQS trigger, but the dispatcher supports the SQS path so one can be added\nwithout touching `handler.py`.\n\n== Registered events\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Handler | Purpose\n\n| `Phone.LineIntelPending` | `LineIntelPendingHandler` | Twilio line-type intelligence for pending `target_phone_numbers`\n| `Phone.TaskDeadlineReminder` | `TaskDeadlineReminderHandler` | Slack reminders for tasks due within 24 h, plus the escalation ladder\n| `Phone.CampaignTargetReEngagement` | `CampaignReEngagementHandler` | Amazon seller campaign re-engagement\n|===\n\n== `Phone.LineIntelPending`\n\nFull rules: xref:business:phone-intelligence.adoc[Phone line intelligence].\n\nSelects `target_phone_numbers` rows where `syncTwilio` is false, capped at\n`PHONE_AGENT_BATCH_SIZE`. Per number:\n\n. `GET` Twilio Lookup v2 with `Fields=line_type_intelligence,line_status`.\n. Update the row with country code, carrier, MCC/MNC, line type, risk level,\n  `isBlocked`, and `syncTwilio = True`.\n. Build HubSpot properties `phone_carrier_name` and `phone_line_type`.\n. Resolve a `hubspotContactId` through the row's `contactId`.\n. Publish `Contact.Update` to `CrmSyncQueue` with `target_phone_number_id` set, so\n  `morpheus-hubspot` stamps the row after a successful PATCH.\n\nReturns immediately with an `ERROR` when `CRM_SYNC_QUEUE_URL` is unset.\n\nRisk tiers above `medium` — `nonFixedVoip` (high), `premium`, `voicemail`, `pager`\n(critical) — set `isBlocked = True`, keeping the number out of future dialling.\n\n`syncTwilio` is the idempotence marker: a number is looked up exactly once, which\nmatters because Twilio Lookup is billed per request.\n\n== `Phone.TaskDeadlineReminder`\n\nFull rules: xref:business:follow-up-tasks.adoc#escalation[escalation ladder].\n\nTwo phases per invocation, both gated on `SLACK_BOT_TOKEN` and\n`SALES_SLACK_CHANNEL_ID` being set:\n\nPhase 1 — reminders::\nPending tasks due within 24 hours, grouped by rep, posted as one Slack channel message.\nAn `Activity` row of type `reminder` is inserted per task as a best-effort audit trail.\n\nPhase 2 — escalation::\nTasks past their deadline by a day or more, escalated *one stage per run* with a\n3-second sleep between tasks for Slack rate limits:\n+\n[cols=\"1,2,1\",options=\"header\"]\n|===\n| Stage | Action | Next\n\n| 0 | Public channel warning mentioning the rep | 1\n| 1 | Slack DM to the rep + `Email.SendInternal` to `EmailAgentQueue` | 2\n| 2 | Mark the task `failed` | 3\n| 3 | Terminal — return | —\n|===\n+\nAt stage 3, a `FollowUpTaskSyncEvent` with status `failed` is published to\n`CrmSyncQueue` when the task has a `hubspotTaskId`.\n\nA per-task failure is logged and the loop continues.\n\n== `Phone.CampaignTargetReEngagement`\n\nFull rules: xref:business:re-engagement.adoc#campaign[Amazon seller campaign].\n\nQueries `v_amazon_seller_contact_engagement_summary` in BigQuery, computes priority in\nPython, resolves ownership, and publishes one `Contact.Update` per row to\n`CrmSyncQueue`.\n\nPriority:\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Condition | `call_priority`\n\n| AI-owned last engagement within `REENGAGEMENT_STALE_DAYS` (3) | 4\n| AI-owned last engagement older, or none | 3\n| Last engagement owner is not the AI | 1\n|===\n\nThis is the only handler in the platform that *aborts the whole batch* when round-robin\nreturns no rep, rather than continuing — every remaining row would otherwise be enrolled\nwith no assignee.\n\nReturns early with a warning when `BIGQUERY_CREDENTIALS` or `CRM_SYNC_QUEUE_URL` is\nunset.\n\n== Services\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Module | Responsibility\n\n| `services/twilio_lookup.py` | Twilio Lookup v2 via `urllib` with Basic auth; maps line type → `LineTypes` enum and → `RiskLevel`; returns a frozen `TwilioLineIntelResult`\n| `services/slack_client.py` | `send_message`, `send_dm`, `resolve_user_id(email)`, `mention(email, name)`\n| `services/sqs_publisher.py` | `publish_to_standard_queue()` — for `EmailAgentQueue`, which is not FIFO and so cannot use `morpheus_common.publish_event()`\n| `utils/task_blocks.py` | `group_by_rep()` and `build_reminder_blocks()` — Slack Block Kit construction\n| `utils/constants.py` | `AI_OWNER_ID`, `REENGAGEMENT_STALE_DAYS`, `TASK_ACTIVE_HOURS`, the BigQuery query, the `outreach_list_reason` string\n|===\n\n[NOTE]\n====\nTwo publishers exist because the two queues differ. `morpheus_common.publish_event()`\nsets `MessageGroupId` and `MessageDeduplicationId`, which are FIFO-only and would be\nrejected by the standard `EmailAgentQueue`.\n====\n\n== Configuration\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Setting | Default | Purpose\n\n| `AGENTCORE_SECRET_ARN` | (template) | Shared secret\n| `SUBSCRIPTION_ID` | (template) | Tenant scope\n| `CRM_SYNC_QUEUE_URL` | `\"\"` | Required by line-intel and campaign re-engagement\n| `EMAIL_AGENT_QUEUE_URL` | `\"\"` | Stage-1 escalation email; skipped with a warning when unset\n| `PHONE_AGENT_BATCH_SIZE` | `25` in template (code default `1`) | Numbers per Twilio run\n| `SALES_SLACK_CHANNEL_ID` | `sales-tasks` | Channel for reminders and escalation\n| `LOG_LEVEL` | `INFO` |\n|===\n\n`PhoneAgentSecrets`: `DATABASE_URL`, `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` (all\nvalidated non-empty), plus optional `SLACK_BOT_TOKEN` and `BIGQUERY_CREDENTIALS`.\n\nBecause the Twilio credentials are validated non-empty, this function will not cold-start\nwithout them — even for the two handlers that never call Twilio.\n\n== IAM\n\n* `SQSSendMessagePolicy` on `CrmSyncQueue`\n* `SQSSendMessagePolicy` on `EmailAgentQueue`\n* `secretsmanager:GetSecretValue` on `AgentcoreSecret`\n\nNo Bedrock access — this function runs no agents.\n\n== Tests\n\n[source,bash]\n----\ncd lambdas && uv run pytest tests/unit/test_phone_agent_twilio_lookup.py -v\ncd lambdas && uv run pytest tests/unit/test_campaign_reengagement.py -v\n----\n\n== Operating notes\n\n* `AI_OWNER_ID` here duplicates `DIALER_FALLBACK_HUBSPOT_OWNER_ID` in\n  `morpheus_hubspot/utils/constants.py`. Both are `\"86817215\"` and both must change\n  together.\n* Empty `SLACK_BOT_TOKEN` disables both reminder phases with a warning; nothing else\n  breaks.\n* Empty `BIGQUERY_CREDENTIALS` disables campaign re-engagement only.\n* To re-run Twilio for a number, clear its `syncTwilio` flag:\n+\n[source,sql]\n----\nUPDATE target_phone_numbers SET \"syncTwilio\" = false WHERE \"phoneNumber\" = '+1302…';\n----\n* All three schedules currently use `rate(...)` values from development. See\n  xref:infrastructure:schedules.adoc[EventBridge schedules].\n"},{"component":"morpheus","module":"lambdas","path":"lambdas:testing.adoc","title":"Testing Lambdas","description":"Test layout, the deferred-import pattern that makes handlers testable, and how to invoke locally.","url":"https://docx.payability.com/morpheus/lambdas/testing.html","navOrder":41,"source":"= Testing Lambdas\n:description: Test layout, the deferred-import pattern that makes handlers testable, and how to invoke locally.\n\n== Layout\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Location | Scope\n\n| `lambdas/tests/unit/` | Cross-function unit tests — BigQuery contact sync, campaign re-engagement, email categorisation, Twilio lookup, the generic handler\n| `lambdas/tests/integration/` | API Gateway integration test\n| `lambdas/tests/conftest.py` | Shared fixtures\n| `lambdas/morpheus_hubspot/tests/` | Handler tests co-located with the function — re-engagement segments, dialer filters, Five9, lifecycle promotion, contact backfill, webhook processor, call-list refresh\n| `lambdas/enrichment_scraper/tests/` | SmartScout provider and auth, plus a live integration test\n| `libs/<lib>/tests/` | Library tests\n|===\n\nTwo conventions coexist: `morpheus_hubspot` and `enrichment_scraper` keep tests inside\nthe function directory (with their own `conftest.py`), while other functions' tests live\nin `lambdas/tests/unit/`.\n\n== Running\n\n[source,bash]\n----\ncd lambdas && uv run pytest tests/ -v                       # cross-function\ncd lambdas && uv run pytest morpheus_hubspot/tests -v       # hubspot handlers\ncd lambdas/enrichment_scraper && uv run pytest tests -v     # scraper\n\ncd libs/morpheus-common && uv run pytest tests -v           # event models\ncd libs/morpheus-data-layer && uv run pytest tests -v       # repositories\ncd libs/pyb-hubspot && uv run pytest tests -v               # HubSpot helpers\n\nmake test                                                    # agentcore runtime\n----\n\n`libs/morpheus-data-layer/tests/integration/` needs a reachable database and reads its\nconnection string from `libs/morpheus-data-layer/.env`.\n`enrichment_scraper/tests/integration/test_smartscout.py` calls the live SmartScout API\nand needs credentials.\n\n== The deferred-import pattern\n\nLambda layer packages are not installed in a test environment. Handler modules therefore\ndefer their imports into `@lru_cache` functions, so the module imports cleanly and the\ntest patches at the function boundary:\n\n[source,python]\n----\n@lru_cache(maxsize=1)\ndef _get_agent():\n    from morpheus_llm.providers import ClaudeAgentService      # deferred\n    from morpheus_llm.types import AgentConfig\n    from morpheus_llm.utils import AgentModelIds\n    return ClaudeAgentService(config=AgentConfig(...), tools=lambda: [])\n----\n\nThe same applies to `load_secrets()` and `create_bigquery_client()` — every scheduled\nhandler imports `morpheus_bigquery` *inside* `handle()` rather than at module scope.\n\n[IMPORTANT]\n====\nPreserve this pattern when adding an agent or a BigQuery-backed handler. A module-level\n`from morpheus_llm... import ...` makes the whole module unimportable without the layer,\nwhich breaks every test that touches it.\n\n`@lru_cache` also means the agent is built once per container. Call\n`_get_agent.cache_clear()` in a test that needs a different configuration.\n====\n\n== What handler tests assert\n\nHandlers are mostly *decision* code, so tests target the decisions rather than the API\ncalls:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Test file | Asserts\n\n| `test_email_click_reengagement.py` | The `_should_skip` suppression rule and the priority passthrough\n| `test_lost_deal_reengagement.py`, `test_unattended_meeting_reengagement.py` | Score-to-priority rounding, the called-today guard, ownership branches\n| `test_dialer_filters.py` | `_effective_disposition()` and every `_skip_reason()` branch\n| `test_lifecycle_stage_promotion.py` | Rank guard — promotion happens only from a lower stage\n| `test_ensure_local_contact_row.py` | The four-step short-circuit and foreign-key repair\n| `test_newsletter_opt_out_processor.py` | `applies_to()` matching, including the subscription-type id check\n| `test_sales_rep_call_list_refresh.py` | Delete-then-create ordering and the id write-back\n| `test_five9_call_sync.py` | Duration and timestamp parsing, disposition mapping, contact resolution\n| `test_bigquery_engagement_sync.py` | Watermark advance and activity upsert columns\n| `test_campaign_reengagement.py` | Priority computation and the abort-on-no-rep behaviour\n|===\n\nHandlers that only orchestrate — `crm_update_activity.py`, `crm_dialer_call_ended.py` —\nhave thin or no direct tests; the logic they delegate to is tested instead.\n\n== Writing a handler test\n\nThe shape that works with this codebase:\n\n[source,python]\n----\nfrom unittest.mock import MagicMock, patch\n\ndef test_skips_when_called_today():\n    row = {\"contact_id\": \"123\", \"last_call_date\": _today_utc(), \"priority_score\": 3.4}\n\n    with patch(\"events.scheduled.supplier_account_reengagement.get_session\"), \\\n         patch(\"events.scheduled.supplier_account_reengagement.update_contact_properties\") as upd, \\\n         patch(\"events.scheduled.supplier_account_reengagement._query_supplier_targets\",\n               return_value=[row]), \\\n         patch(\"events.scheduled.supplier_account_reengagement.load_secrets\") as secrets:\n        secrets.return_value.bigquery_credentials = \"{}\"\n        SupplierAccountReEngagementHandler().handle(ScheduledEvent(), MagicMock())\n\n    upd.assert_not_called()\n----\n\nPatch the module-level *name* the handler resolves (`events.scheduled.x.get_session`),\nnot the definition site — the handler imported it into its own namespace at import time.\n\nPure functions — `_score_to_call_priority`, `_compute_priority`, `_should_skip`,\n`normalize_phone`, `_effective_disposition`, `_skip_reason` — are the highest-value test\ntargets and need no patching at all.\n\n== Local invocation\n\n[source,bash]\n----\ncd lambdas\nsam build\nsam local invoke MorpheusHubspotFunction \\\n  --event morpheus_hubspot/events/samples/contact_update.json\n----\n\nSample payloads live in `<function>/events/samples/`, each directory with a `README.md`\ndescribing when to use which. For a scheduled handler, hand-write the envelope:\n\n[source,json]\n----\n{\"detail-type\": \"Hubspot.EmailClickReEngagement\", \"source\": \"morpheus.hubspot\", \"detail\": {}}\n----\n\n[WARNING]\n====\n`sam local invoke` reaches *real* AWS for Secrets Manager, SQS, Bedrock, and the\ndatabase. A local run of a re-engagement handler will PATCH production HubSpot contacts.\nTreat every local invocation as production traffic.\n====\n\nSafer alternatives: invoke the deployed function with a narrow payload (for example\n`Contact.ResolveHubSpotIds` with `batch_limit: 1`), or unit-test the decision function\ndirectly.\n\n== Sample payloads\n\n`morpheus_hubspot/events/samples/` covers `contact_update`, `contact_unsubscribe`,\n`contact_resolve_hubspot_ids`, `crm_custom_event_agent_email`,\n`crm_custom_event_agent_email_clicked`, `crm_dialer_call_ended`,\n`crm_hubspot_webhook_newsletter_optout`, `crm_new_activity`, `crm_new_activity_incoming`,\n`crm_task_create`, `crm_update_activity`, and `seller_crm_sync`.\n\n`email_agent/events/samples/` covers the two `Email.IncomingCategorise` resolution\npaths. `phone_agent/events/samples/` has `phone_line_intel_pending.json`.\n`lambdas/events/` holds two older top-level fixtures (`event.json`,\n`sellers_sqs.json`).\n\nAdd a sample with every new event type — it doubles as documentation and as a\n`sam local invoke` fixture.\n\n== CI\n\n`.github/workflows/security-review.yml` runs a security review over pull-request diffs.\nThere is no test-running workflow — tests are run locally.\n"},{"component":"morpheus","module":"events","path":"events:index.adoc","title":"Events","description":"How Morpheus components communicate — transports, routing keys, and the event design rules.","url":"https://docx.payability.com/morpheus/events/index.html","navOrder":42,"source":"= Events\n:description: How Morpheus components communicate — transports, routing keys, and the event design rules.\n\nEvery cross-component interaction in Morpheus is an event. No function calls another\nfunction; nothing shares code at runtime. That makes the event catalog the platform's\nreal interface contract.\n\n== Transports\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Transport | Queue | Routing key location | Consumers\n\n| SQS FIFO | `EnrichmentPipelineQueue.fifo` | `event_type` *message attribute* | enrichment-scorer\n| SQS FIFO | `CrmSyncQueue.fifo` | `event_type` field *in the body* | morpheus-hubspot\n| SQS standard | `EmailAgentQueue` | `event_type` *message attribute* | email-agent\n| EventBridge | — | `detail-type` at the event root | all five functions\n|===\n\n[IMPORTANT]\n====\nThe routing key lives in a *different place* per queue. `CrmSyncQueue` consumers read\n`event_type` from the JSON body — which is also why a `CrmSyncQueue` message may be a\nJSON *array* of events. The other two queues read the `event_type` SQS message\nattribute, so their bodies need not carry it (though the models do).\n====\n\n== Pages\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Page | Contents\n\n| xref:catalog.adoc[Event catalog] | Every event type in one table, with producer and consumer\n| xref:crm-sync-queue.adoc[CrmSyncQueue events] | The 12 CRM event schemas\n| xref:enrichment-pipeline-queue.adoc[EnrichmentPipelineQueue events] | `SellerRecord.Received`\n| xref:email-agent-queue.adoc[EmailAgentQueue events] | `Email.IncomingCategorise`, `Resend.InboundEmail`\n| xref:scheduled.adoc[Scheduled events] | All 14 EventBridge `detail-type` values\n| xref:hubspot-webhooks.adoc[HubSpot webhooks] | The inbound webhook envelope and its processors\n| xref:publishing.adoc[Publishing events] | `publish_event()`, FIFO keys, deduplication\n| xref:adding-an-event.adoc[Adding a new event] | The five-step walkthrough\n|===\n\n== Design rules\n\nFat events::\nAn event carries everything its consumer needs, so the consumer performs no database\nread purely to do its job. `Seller.CrmSync` embeds the company fields *and* the full\ncontact list, including each contact's `internal_id` so the consumer can write ids back.\n+\nThe exception is deliberate: `Crm.DialerCallEnded` is thin (`call_id`, `target_id`,\n`campaign_id`) because the `Call` row it points at is large, mutable, and authoritative.\nEmbedding it would risk acting on stale data.\n\nPydantic models are the schema::\nEvery event has a model in `libs/morpheus-common/morpheus_common/events/models.py`\n(cross-function) or in the function's own `models/` (function-local). The model *is* the\ncontract — there is no separate schema registry. Cross-field requirements go in a\n`model_validator(mode=\"after\")` so an invalid message is rejected at the boundary rather\nthan mid-handler.\n\n`event_type` defaults on the model::\n[source,python]\n----\nclass ContactUpdateEvent(BaseModel):\n    event_type: str = \"Contact.Update\"\n----\n+\nA producer constructs the model and the routing key comes for free. It cannot drift from\nthe registry key without someone editing the default.\n\nValidate, then decide::\nThe dispatcher validates. The handler decides. A handler never re-parses raw JSON.\n\nBad data is dropped, transient failure raises::\nA subscription mismatch, a missing row, or a malformed UUID logs at `ERROR` and returns —\nthe message is deleted, because retrying cannot fix it. A Bedrock error or a HubSpot 5xx\nraises, so the message lands in the DLQ for inspection or replay.\n\n== Naming\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Convention | Pattern | Examples\n\n| SQS event types | `<Domain>.<Action>` | `Seller.CrmSync`, `Contact.Update`, `Crm.TaskCreate`, `FollowUpTask.Sync`\n| Scheduled detail-types | `<Function>.<Job>` | `Hubspot.EmailClickReEngagement`, `Phone.LineIntelPending`, `Scraper.BigQueryContactSync`, `Seller.EnrichPending`\n|===\n\nThe scheduled prefix names the *owning function*, which makes it obvious from a\nCloudWatch log line which Lambda a schedule belongs to.\n\n== Reliability\n\nAll three queues use `maxReceiveCount: 1` — no automatic retry. A single handler failure\nsends the message straight to its dead-letter queue, retained 14 days.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Queue | DLQ\n\n| `{sam-stack}-enrichment-pipeline.fifo` | `{sam-stack}-enrichment-pipeline-dlq.fifo`\n| `{sam-stack}-crm-sync.fifo` | `{sam-stack}-crm-sync-dlq.fifo`\n| `{sam-stack}-email-agent` | `{sam-stack}-email-agent-dlq`\n|===\n\nEvery SQS trigger declares `FunctionResponseTypes: [ReportBatchItemFailures]`, so a\nfailure isolates to the offending message rather than the batch. `BatchSize` is 1\neverywhere today.\n\nReplay and inspection: xref:ROOT:operations.adoc#queues[Operations].\n\n== Idempotence\n\nConsumers assume redelivery is possible and are written to tolerate it:\n\n* Database writes are `ON CONFLICT DO UPDATE` upserts on natural keys (`sellerId`,\n  `providerCallId`, `(subscriptionId, externalId)`) or find-or-create.\n* Expensive steps are guarded by a completion marker: `enrichedAt`, `payoutPainScore`,\n  `syncTwilio`, `hubspotSyncStatus`.\n* HubSpot's \"already has that value\" response is treated as *success*, not failure.\n* FIFO deduplication ids are content-derived, so a duplicate publish inside the\n  five-minute window is collapsed.\n\nThe known non-idempotent operation is HubSpot Call engagement creation in the Five9 sync\n— see xref:business:call-logging.adoc[Call logging].\n"},{"component":"morpheus","module":"events","path":"events:catalog.adoc","title":"Event catalog","description":"Every event type in Morpheus, with its transport, producer, consumer, and model.","url":"https://docx.payability.com/morpheus/events/catalog.html","navOrder":43,"source":"= Event catalog\n:description: Every event type in Morpheus, with its transport, producer, consumer, and model.\n\nThe authoritative source is each function's `registry.py`. When this table and a registry\ndisagree, the registry wins — and this page needs updating.\n\n== SQS events\n\n=== `CrmSyncQueue.fifo` → morpheus-hubspot\n\nRouting key: the `event_type` field *inside the message body*.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `event_type` | Model | Producer | Purpose\n\n| `Seller.CrmSync` | `SellerSyncEvent` | enrichment-scorer | Upsert a HubSpot Company + Contacts, write ids back\n| `Contact.Update` | `ContactUpdateEvent` | phone-agent, web app | Generic Contact PATCH by HubSpot id\n| `Contact.Unsubscribe` | `ContactUnsubscribeEvent` | web app | Update Communication Preferences\n| `Contact.ResolveHubSpotIds` | `ContactResolveHubSpotIdsEvent` | manual, web app | Trigger-only backfill of `hubspotContactId`\n| `Crm.NewActivity` | `CrmNewActivityEvent` | web app | Create a logged Email object (+ follow-up Task, + AgentEmail event)\n| `Crm.UpdateActivity` | `CrmUpdateActivityEvent` | web app | PATCH an existing Email object\n| `Crm.CustomEvent` | `CrmCustomEventEnvelope` | web app | Send a whitelisted behavioural event\n| `Crm.TaskCreate` | `CrmTaskCreateEvent` | web app | Create a HubSpot Task, mirror in `follow_up_tasks`\n| `Crm.DealUpdate` | `CrmDealUpdateEvent` | web app | Set a Deal stage\n| `Crm.DialerCallEnded` | `CrmDialerCallEndedEvent` | web app dialer | Sync a completed AI call to HubSpot (+ Nooks)\n| `Crm.HubSpotWebhook` | `CrmHubSpotWebhookEvent` | webhook relay | Dispatch inbound HubSpot notifications\n| `FollowUpTask.Sync` | `FollowUpTaskSyncEvent` | phone-agent, web app | Sync a task's status to HubSpot\n|===\n\nSchemas: xref:crm-sync-queue.adoc[CrmSyncQueue events].\n\n=== `EnrichmentPipelineQueue.fifo` → enrichment-scorer\n\nRouting key: the `event_type` SQS *message attribute*.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `event_type` | Model | Producer | Purpose\n\n| `SellerRecord.Received` | `SellerRecord` | enrichment-scraper | A discovered seller to persist, enrich, and score\n|===\n\nSchema: xref:enrichment-pipeline-queue.adoc[EnrichmentPipelineQueue events].\n\n=== `EmailAgentQueue` → email-agent\n\nStandard queue. Routing key: the `event_type` SQS *message attribute*.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `event_type` | Model | Producer | Purpose\n\n| `Email.IncomingCategorise` | `IncomingCategoriseEvent` | web app | Classify an inbound reply\n| `Resend.InboundEmail` | `ResendEmailReceivedEvent` | Resend webhook relay | Inbound email received — logging stub\n| `Email.SendInternal` | *none registered* | phone-agent | Stage-1 escalation email — dropped, no handler\n|===\n\nSchemas: xref:email-agent-queue.adoc[EmailAgentQueue events].\n\n== Scheduled events\n\nRouting key: `detail-type` at the event root, set by the rule's `Input`.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Consumer | Rule enabled | Purpose\n\n| `SellerDiscovery.Amazon` | enrichment-scraper | *commented out* | SmartScout seller discovery\n| `SellerDiscovery.Walmart` | enrichment-scraper | *commented out* | Stub — raises `NotImplementedError`\n| `SellerDiscovery.RunCategory` | enrichment-scraper | no rule | Stub — ad-hoc per-category discovery\n| `Scraper.BigQueryContactSync` | enrichment-scraper | yes | Mirror HubSpot contacts from BigQuery\n| `Seller.EnrichPending` | enrichment-scorer | *commented out* | Backfill enrichment\n| `Seller.ScorePending` | enrichment-scorer | *commented out* | Backfill payout-pain scores\n| `Hubspot.FollowUpTaskPoll` | morpheus-hubspot | yes | Poll HubSpot for completed tasks\n| `Hubspot.ContactResolve` | morpheus-hubspot | yes | Wide-search HubSpot id resolution\n| `Hubspot.BigQueryEngagementSync` | morpheus-hubspot | yes | Mirror engagements into `Activity`\n| `Hubspot.EmailClickReEngagement` | morpheus-hubspot | yes | Email-click segment\n| `Hubspot.SupplierAccountReEngagement` | morpheus-hubspot | yes | Supplier account segment\n| `Hubspot.UnattendedMeetingReEngagement` | morpheus-hubspot | yes | Meeting no-show segment\n| `Hubspot.LostDealReEngagement` | morpheus-hubspot | yes | Lost/stalled deal segment\n| `Hubspot.SalesRepCallListRefresh` | morpheus-hubspot | yes | Rebuild per-rep HubSpot call lists\n| `Hubspot.Five9CallSync` | morpheus-hubspot | yes | Import Five9 CSV call logs\n| `Phone.LineIntelPending` | phone-agent | yes | Twilio line intelligence\n| `Phone.TaskDeadlineReminder` | phone-agent | yes | Slack reminders + escalation\n| `Phone.CampaignTargetReEngagement` | phone-agent | yes | Amazon seller campaign segment\n|===\n\nCadences: xref:infrastructure:schedules.adoc[EventBridge schedules]. Details:\nxref:scheduled.adoc[Scheduled events].\n\n== AgentCore workflows\n\nNot events, but the third named-invocation surface. Registered in\n`agentcore/workflows/__init__.py`.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Workflow | Payload | Purpose\n\n| `test` | `{\"prompt\": \"…\"}` | Direct prompt passthrough\n| `dialler-agent` | `{\"phoneNumber\": \"…\"}` | Analyse HubSpot history, prepare a call brief\n|===\n\nSee xref:agentcore:invocation.adoc[Invocation contract].\n\n== Where models live\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Location | Contents\n\n| `libs/morpheus-common/morpheus_common/events/models.py` | Every cross-function event: `SellerSyncEvent`, `ContactSyncPayload`, `ContactUpdateEvent`, `ContactUnsubscribeEvent`, `SubscriptionPreferenceUpdate`, `ContactResolveHubSpotIdsEvent`, `CrmNewActivityEvent`, `CrmUpdateActivityEvent`, `CrmTaskCreateEvent`, `CrmDealUpdateEvent`, `CrmDialerCallEndedEvent`, `CrmHubSpotWebhookEvent`, `HubSpotWebhookInnerEvent`, `FollowUpTaskSyncEvent`\n| `libs/morpheus-common/morpheus_common/events/custom_events.py` | `CustomEventKind`, `CrmCustomEventAgentEmail`, `CrmCustomEventEnvelope`, and the HubSpot event-name helpers\n| `lambdas/enrichment_scraper/models/` | `SellerRecord`, `SellerDiscoveryEvent`, `RunCategoryEvent`, `ScheduledEvent`\n| `lambdas/enrichment_scorer/models/` | `SellerRecord` (consumer-side), `ScheduledEvent`\n| `lambdas/email_agent/models/` | `IncomingCategoriseEvent`, `ResendEmailReceivedEvent`, `ScheduledEvent`\n| `lambdas/phone_agent/models/events.py` | `ScheduledEvent`\n| `lambdas/morpheus_hubspot/models/config.py` | `ScheduledEvent`\n|===\n\nEach function defines its own `ScheduledEvent` — a permissive `extra=\"ignore\"` model with\na single optional `source` field. They are not shared, because a scheduled event carries\nno payload worth a shared contract.\n\n== Coverage gaps\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Gap | Status\n\n| `Email.SendInternal` | Published by `phone-agent`, no handler registered. Messages are logged and deleted.\n| `Resend.InboundEmail` | Registered, but the handler only logs. Intended behaviour is in its module docstring.\n| `SellerDiscovery.Walmart` | Registered, raises `NotImplementedError`. Rule disabled.\n| `SellerDiscovery.RunCategory` | Registered, raises `NotImplementedError`. No rule.\n| `Crm.NewActivity` `activity_kind` | Only `\"email\"` supported; anything else raises.\n| `Crm.UpdateActivity` `object_type` | Only `\"emails\"` supported; anything else raises.\n| `Crm.CustomEvent` `custom_event_kind` | Only `agent_email` whitelisted.\n|===\n"},{"component":"morpheus","module":"events","path":"events:crm-sync-queue.adoc","title":"CrmSyncQueue events","description":"The twelve event schemas consumed by morpheus-hubspot from CrmSyncQueue.","url":"https://docx.payability.com/morpheus/events/crm-sync-queue.html","navOrder":44,"source":"= CrmSyncQueue events\n:description: The twelve event schemas consumed by morpheus-hubspot from CrmSyncQueue.\n\nQueue: `{sam-stack}-crm-sync.fifo`. Consumer:\nxref:lambdas:morpheus-hubspot.adoc[morpheus-hubspot]. Models:\n`libs/morpheus-common/morpheus_common/events/models.py` and `custom_events.py`.\n\n[IMPORTANT]\n====\nThe routing key is the `event_type` field *inside the JSON body*, not an SQS message\nattribute. A body may also be a JSON *array* of event objects, each dispatched\nindividually; if any element fails, the whole message is marked failed.\n====\n\nSample payloads live in `lambdas/morpheus_hubspot/events/samples/`.\n\n== `Seller.CrmSync`\n\nProducer: enrichment-scorer. The canonical fat event — everything needed to create a\nCompany and its Contacts without a database read.\n\n[source,python]\n----\nclass ContactSyncPayload(BaseModel):\n    internal_id: str | None          # contacts.id — used to write hubspotContactId back\n    first_name: str | None\n    last_name: str | None\n    job_title: str | None\n    email: str | None\n    phone: str | None\n    hubspot_contact_id: str | None    # skip the CRM search when already known\n    life_cycle_stage: str | None      # synced to the mor_life_cycle_stage Contact property\n\nclass SellerSyncEvent(BaseModel):\n    event_type: str = \"Seller.CrmSync\"\n    seller_id: str                    # sellers.sellerId — external marketplace id\n    seller_internal_id: str           # sellers.id — for the hubspotCompanyId write-back\n    subscription_id: str\n    name: str | None\n    platform: str                     # required\n    website: str | None\n    linkedin_url: str | None\n    industry: str | None\n    annual_revenue: int | None\n    number_of_employees: int | None\n    description: str | None\n    hubspot_company_id: str | None    # skip the CRM search when already known\n    contacts: list[ContactSyncPayload] = []\n----\n\n[source,json]\n----\n{\n  \"event_type\": \"Seller.CrmSync\",\n  \"seller_id\": \"ATVPDKIKX0DER\",\n  \"seller_internal_id\": \"00000000-0000-4000-8000-000000000001\",\n  \"subscription_id\": \"b191a905-5d19-4fa0-941b-d8eb81c0c477\",\n  \"name\": \"Example Seller\",\n  \"platform\": \"amazon\",\n  \"contacts\": []\n}\n----\n\nFIFO keys: `MessageGroupId = seller_id`,\n`MessageDeduplicationId = sha256(\"crm-sync-\" + seller_id)`.\n\n== `Contact.Update`\n\nProducers: phone-agent, the web application. The generic Contact PATCH — no CRM search,\nno local lookup.\n\n[source,python]\n----\nclass ContactUpdateEvent(BaseModel):\n    event_type: str = \"Contact.Update\"\n    subscription_id: str\n    hubspot_contact_id: str                     # required, min_length=1\n    properties: dict[str, str]                  # required, must be non-empty\n    target_phone_number_id: str | None          # write-back target after a successful PATCH\n\n    @model_validator(mode=\"after\")\n    def _non_empty_properties(self): ...\n----\n\n[source,json]\n----\n{\n  \"event_type\": \"Contact.Update\",\n  \"subscription_id\": \"b191a905-5d19-4fa0-941b-d8eb81c0c477\",\n  \"hubspot_contact_id\": \"18719954\",\n  \"properties\": { \"phone_carrier_name\": \"Example Carrier\", \"phone_line_type\": \"mobile\" },\n  \"target_phone_number_id\": \"00000000-0000-4000-8000-000000000001\"\n}\n----\n\nThis is the event the campaign re-engagement segment and the Twilio line-intelligence job\nboth use. See xref:business:re-engagement.adoc#campaign[campaign re-engagement].\n\n== `Contact.Unsubscribe`\n\nUpdates HubSpot Communication Preferences. At least one of `hubspot_contact_id` or\n`contact_email` is required.\n\n[source,python]\n----\nSubscriptionPreferenceStatus = Literal[\"SUBSCRIBED\", \"UNSUBSCRIBED\", \"NOT_SPECIFIED\"]\n\nclass SubscriptionPreferenceUpdate(BaseModel):\n    hubspot_subscription_type_id: str    # validated: must be all digits\n    status: SubscriptionPreferenceStatus\n\nclass ContactUnsubscribeEvent(BaseModel):\n    event_type: str = \"Contact.Unsubscribe\"\n    subscription_id: str\n    hubspot_contact_id: str | None\n    contact_email: str | None\n    subscription_updates: list[SubscriptionPreferenceUpdate]   # min_length=1\n    legal_basis: str | None\n    legal_basis_explanation: str | None\n\n    @model_validator(mode=\"after\")\n    def _require_identifier(self): ...        # id or email\n----\n\n`contact_email` is preferred when present; otherwise the handler reads the email from the\nCRM by id.\n\n== `Contact.ResolveHubSpotIds`\n\nTrigger-only. The handler loads candidates itself, so the payload carries no PII.\n\n[source,python]\n----\nclass ContactResolveHubSpotIdsEvent(BaseModel):\n    event_type: str = \"Contact.ResolveHubSpotIds\"\n    batch_limit: int | None      # ge=1, le=3000; falls back to the Lambda default\n----\n\n== `Crm.NewActivity`\n\nCreates a logged HubSpot Email object.\n\n[source,python]\n----\nclass CrmNewActivityEvent(BaseModel):\n    event_type: str = \"Crm.NewActivity\"\n    activity_kind: str = \"email\"           # only \"email\" supported; anything else raises\n    hubspot_contact_id: str | None\n    hubspot_company_id: str | None\n    campaign: str | None                   # goes on the follow-up AgentEmail event,\n                                           # not on the Email object\n    hubspot_owner_id: str | None           # CRM owner id — assigns the incoming-email\n                                           # follow-up Task\n    properties: dict[str, str]             # validated with EmailActivityCreateProperties\n    idempotency_key: str | None            # producers may use for MessageDeduplicationId\n\n    @property\n    def has_association(self) -> bool: ...  # true when a contact or company is present\n----\n\nAt least one association target is required, or the handler logs a warning and returns.\n\nWhen `hs_email_direction` is `INCOMING_EMAIL` and `hubspot_owner_id` is set, a follow-up\nTask is created for that owner. An `AgentEmail` behavioural event is sent afterwards.\n\n[WARNING]\n====\n`hubspot_owner_id` must be the CRM owner `id` from `GET /crm/v3/owners` — *not* the\nnumber in a HubSpot settings URL, an email address, or a contact id. A wrong id often\nstill creates the Task but assigns it to another user.\n====\n\n== `Crm.UpdateActivity`\n\n[source,python]\n----\nclass CrmUpdateActivityEvent(BaseModel):\n    event_type: str = \"Crm.UpdateActivity\"\n    object_type: str = \"emails\"       # only \"emails\" supported; anything else raises\n    hubspot_object_id: str\n    properties: dict[str, str]        # validated with EmailActivityUpdateProperties\n----\n\n== `Crm.CustomEvent`\n\nA whitelisted HubSpot behavioural event. The wire format is a discriminated union on\n`custom_event_kind`, wrapped in a `RootModel` so `model_validate(body)` works in the\nregistry.\n\n[source,python]\n----\nclass CustomEventKind(StrEnum):\n    AGENT_EMAIL = \"agent_email\"\n\nclass CrmCustomEventAgentEmail(BaseModel):\n    event_type: Literal[\"Crm.CustomEvent\"] = \"Crm.CustomEvent\"\n    custom_event_kind: Literal[CustomEventKind.AGENT_EMAIL]\n    hubspot_contact_object_id: str\n    hubspot_event_name_override: str | None\n    status: Literal[\"opened\", \"clicked\", \"bounced\", \"complained\",\n                    \"unsubscribed\", \"delivered\", \"replied\", \"sent\"]\n    campaign: str | None\n    link_url: str | None\n    campaign_id: str | None          # local campaign UUID, links follow-up tasks\n\nCrmCustomEventRoot = Annotated[Union[CrmCustomEventAgentEmail],\n                               Field(discriminator=\"custom_event_kind\")]\nclass CrmCustomEventEnvelope(RootModel[CrmCustomEventRoot]): ...\n----\n\n`status`, `campaign`, and `link_url` map to HubSpot properties of the same names. The\nevent is sent to `POST /events/v3/send` as `pe{hubspot-portal}_agentemail` unless\noverridden.\n\nAn unknown `custom_event_kind` fails validation before any API call — the whitelist is\nthe security boundary.\n\n== `Crm.TaskCreate`\n\n[source,python]\n----\nclass CrmTaskCreateEvent(BaseModel):\n    event_type: str = \"Crm.TaskCreate\"\n    contact_id: str | None                # internal contacts.id UUID\n    hubspot_contact_id: str | None\n    hubspot_company_id: str | None        # optional association\n    hubspot_owner_id: str                 # required — assignment + sales_reps lookup\n    task_type: str                        # \"call\" | \"email\" | \"todo\"\n    subject: str                          # min_length=1 → hs_task_subject\n    due_at: datetime | None               # → hs_timestamp\n    priority: str = \"MEDIUM\"              # LOW | MEDIUM | HIGH\n    body: str | None                      # → hs_task_body\n    campaign_target_id: str | None\n\n    @model_validator(mode=\"after\")\n    def _require_contact(self): ...        # contact_id or hubspot_contact_id\n----\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.TaskCreate\",\n  \"hubspot_contact_id\": \"12345678\",\n  \"hubspot_owner_id\": \"98765432\",\n  \"task_type\": \"call\",\n  \"subject\": \"Follow up with prospect\",\n  \"due_at\": \"2026-06-13T14:00:00Z\",\n  \"priority\": \"HIGH\",\n  \"body\": \"Discuss pricing options\"\n}\n----\n\n== `Crm.DealUpdate`\n\n[source,python]\n----\nclass CrmDealUpdateEvent(BaseModel):\n    event_type: str = \"Crm.DealUpdate\"\n    deal_stage: str                       # required, min_length=1\n    hubspot_deal_id: str | None           # update this specific deal\n    hubspot_contact_id: str | None        # or every open deal for this contact\n    note: str | None                      # logged on each deal before the stage change\n\n    @model_validator(mode=\"after\")\n    def _require_one_identifier(self): ...\n----\n\n== `Crm.DialerCallEnded`\n\nDeliberately *thin*: the `Call` and `CampaignTarget` rows are large, mutable, and\nauthoritative, so the consumer loads them rather than trusting an embedded copy.\n\n[source,python]\n----\nclass CrmDialerCallEndedEvent(BaseModel):\n    model_config = ConfigDict(str_strip_whitespace=True, extra=\"ignore\")\n    event_type: str = \"Crm.DialerCallEnded\"\n    source: str = \"dialer\"\n    subscription_id: str\n    call_id: str\n    target_id: str\n    campaign_id: str\n    provider_call_id: str | None\n    campaign_name: str | None\n    disposition: str | None          # descriptive; the handler recomputes it from the row\n    duration_sec: int | None\n----\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.DialerCallEnded\",\n  \"source\": \"dialer\",\n  \"subscription_id\": \"b191a905-5d19-4fa0-941b-d8eb81c0c477\",\n  \"call_id\": \"00000000-0000-4000-8000-000000000001\",\n  \"target_id\": \"00000000-0000-4000-8000-000000000002\",\n  \"campaign_id\": \"00000000-0000-4000-8000-000000000003\",\n  \"provider_call_id\": \"retell-call-abc\",\n  \"disposition\": \"Qualified_Lead\",\n  \"duration_sec\": 120\n}\n----\n\nConsumer behaviour: xref:business:call-logging.adoc[Call logging and dispositions].\n\n== `Crm.HubSpotWebhook`\n\nA batch of inbound HubSpot notifications relayed by an external forwarder. Inner events\narrive in HubSpot's camelCase and are accepted in either case via `AliasChoices`.\n\n[source,python]\n----\nclass HubSpotWebhookInnerEvent(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\", populate_by_name=True)\n    subscription_type: str                # subscriptionType\n    object_id: int                        # objectId — numeric strings are coerced\n    occurred_at: int                      # occurredAt, epoch milliseconds\n    property_name: str | None             # propertyName\n    property_value: str | None            # propertyValue\n    hubspot_event_id: int | None\n    hubspot_webhook_subscription_id: int | None\n    attempt_number: int | None\n    change_source: str | None\n    app_id: int | None\n    portal_id: int | None\n    source_id: str | None\n\nclass CrmHubSpotWebhookEvent(BaseModel):\n    event_type: Literal[\"Crm.HubSpotWebhook\"] = \"Crm.HubSpotWebhook\"\n    source: str\n    portal_id: int                        # portalId\n    events: list[HubSpotWebhookInnerEvent]   # min_length=1\n----\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.HubSpotWebhook\",\n  \"source\": \"hubspot-forwarder\",\n  \"portalId\": 12345678,\n  \"events\": [\n    {\n      \"subscriptionType\": \"contact.propertyChange\",\n      \"objectId\": 987654321,\n      \"occurredAt\": 1713628800000,\n      \"propertyName\": \"hs_email_optout_44467216\",\n      \"propertyValue\": \"true\"\n    }\n  ]\n}\n----\n\nProcessors: xref:hubspot-webhooks.adoc[HubSpot webhooks].\n\n== `FollowUpTask.Sync`\n\n[source,python]\n----\nclass FollowUpTaskSyncEvent(BaseModel):\n    event_type: str = \"FollowUpTask.Sync\"\n    task_id: str              # internal follow_up_tasks.id UUID\n    hubspot_task_id: str      # HubSpot task engagement id\n    status: str               # a FollowUpTaskStatus value\n----\n\nThe handler reads HubSpot *first*: an already-completed task is accepted rather than\noverwritten. See xref:business:follow-up-tasks.adoc[Follow-up tasks and escalation].\n\n== Common guards\n\nEvery handler on this queue applies these before doing work; each logs at `ERROR` and\nreturns *without raising*, so the message is deleted rather than retried:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Guard | Applies to\n\n| `subscription_id` must match the function's `SUBSCRIPTION_ID`\n| `Contact.Update`, `Contact.Unsubscribe`, and other tenant-scoped events\n\n| Non-empty `properties`\n| `Contact.Update` (also enforced by a `model_validator`)\n\n| Required association present\n| `Crm.NewActivity`\n\n| Supported `activity_kind` / `object_type` / `custom_event_kind`\n| `Crm.NewActivity`, `Crm.UpdateActivity`, `Crm.CustomEvent` — these *raise*, since an\n  unsupported value is a producer bug worth surfacing\n|===\n"},{"component":"morpheus","module":"events","path":"events:enrichment-pipeline-queue.adoc","title":"EnrichmentPipelineQueue events","description":"The SellerRecord.Received event that carries a discovered seller into the enrichment pipeline.","url":"https://docx.payability.com/morpheus/events/enrichment-pipeline-queue.html","navOrder":45,"source":"= EnrichmentPipelineQueue events\n:description: The SellerRecord.Received event that carries a discovered seller into the enrichment pipeline.\n\nQueue: `{sam-stack}-enrichment-pipeline.fifo`. Producer:\nxref:lambdas:enrichment-scraper.adoc[enrichment-scraper]. Consumer:\nxref:lambdas:enrichment-scorer.adoc[enrichment-scorer].\n\nRouting key: the `event_type` SQS *message attribute*, set to `SellerRecord.Received`.\n\n[NOTE]\n====\nThe SQS trigger on `enrichment-scorer` is currently commented out in\n`lambdas/template.yaml`, so published messages accumulate on the queue (retained four\ndays) until it is enabled. See xref:infrastructure:schedules.adoc[EventBridge schedules].\n====\n\n== `SellerRecord.Received`\n\nModel: `SellerRecord` — defined independently in *both* functions\n(`enrichment_scraper/models/seller.py` and `enrichment_scorer/models/seller.py`), since\nthe units may not import each other.\n\n[source,python]\n----\nclass Marketplace(StrEnum):\n    AMAZON = \"amazon\"\n    WALMART = \"walmart\"\n\nclass DataSource(StrEnum):\n    SMARTSCOUT = \"smartscout\"\n    JUNGLE_SCOUT = \"jungle_scout\"\n    WALMART_SCRAPER = \"walmart_scraper\"\n\nclass SellerRecord(BaseModel):\n    model_config = ConfigDict(str_strip_whitespace=True, frozen=True)\n\n    seller_id: str                       # external marketplace id, e.g. ATVPDKIKX0DER\n    marketplace: Marketplace\n    business_name: str\n    storefront_url: str\n    estimated_monthly_revenue: float     # validator: must be >= 0\n    asin_count: int                      # validator: must be >= 0\n    primary_category: str\n    seller_since: date | None\n    percent_fba: float | None\n    mom_growth: float | None\n    num_brands: int | None\n    state: str | None\n    country: str | None\n    source: DataSource\n    raw: dict                            # the full provider payload\n----\n\n`frozen=True` makes the record immutable once validated: a handler cannot accidentally\nmutate the event it is processing. `str_strip_whitespace=True` normalises every string\nfield on the way in.\n\n`raw` carries the entire SmartScout row, so a future consumer can extract a field that\nwas not mapped without re-fetching from the provider.\n\n== Example\n\n[source,json]\n----\n{\n  \"seller_id\": \"ATVPDKIKX0DER\",\n  \"marketplace\": \"amazon\",\n  \"business_name\": \"Example Trading Co\",\n  \"storefront_url\": \"https://www.amazon.com/sp?seller=ATVPDKIKX0DER\",\n  \"estimated_monthly_revenue\": 42000.0,\n  \"asin_count\": 87,\n  \"primary_category\": \"Home & Kitchen\",\n  \"seller_since\": \"2022-03-14\",\n  \"percent_fba\": 0.92,\n  \"mom_growth\": 8.4,\n  \"num_brands\": 3,\n  \"state\": \"DE\",\n  \"country\": \"US\",\n  \"source\": \"smartscout\",\n  \"raw\": { \"amazonSellerId\": \"ATVPDKIKX0DER\", \"…\": \"…\" }\n}\n----\n\n== SQS message construction\n\n`QueueService.publish()` sends `send_message_batch` calls of 10:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Value\n\n| `MessageBody` | `record.model_dump_json()`\n| `MessageGroupId` | `record.seller_id`\n| `MessageDeduplicationId` | `sha256(seller_id + marketplace)`\n| `MessageAttributes.event_type` | `{\"DataType\": \"String\", \"StringValue\": \"SellerRecord.Received\"}`\n|===\n\n=== Why one group per seller\n\nGrouping by marketplace would serialise the entire pipeline behind two FIFO groups,\ncapping the scorer at two concurrent invocations regardless of queue depth. One group per\nseller preserves ordering *for that seller* — which is all that matters, since the\nconsumer upserts by `sellerId` — while allowing wide parallelism.\n\n=== Deduplication\n\nThe id is derived from seller identity, not from message content, so the same seller\ndiscovered twice inside SQS's five-minute deduplication window is enqueued once.\n`ContentBasedDeduplication` is `false` on the queue, so the explicit id is required.\n\n== Consumer behaviour\n\n`SellerReceivedHandler` runs four steps, only the first of which is allowed to fail the\nmessage:\n\n. `_upsert_seller()` — `ON CONFLICT (sellerId) DO UPDATE`. Commits.\n. `_enrich()` — AI enrichment, skipped when `enrichedAt` is set. Best-effort.\n. `_apply_enrichment()` — write results, publish `Seller.CrmSync`. Best-effort.\n. `_score()` + `_apply_score()` — payout-pain score, skipped when already set.\n  Best-effort.\n\nFull detail: xref:business:seller-enrichment.adoc[Seller enrichment].\n\n=== Backward compatibility\n\nThe consumer defaults its routing key to `SellerRecord.Received` when the `event_type`\nmessage attribute is absent (`_DEFAULT_SQS_EVENT_TYPE` in `handler.py`), for messages\npublished before the attribute was added.\n\n== Failure handling\n\n`maxReceiveCount: 1` — a handler failure sends the message straight to\n`{sam-stack}-enrichment-pipeline-dlq.fifo`, retained 14 days.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Failure | Fate\n\n| Malformed JSON body | DLQ\n| `ValidationError` (for example negative revenue) | DLQ\n| Unknown `event_type` | deleted — retrying cannot route it\n| Step 1 raised | DLQ\n| Steps 2–4 raised | *message acked* — the seller row is committed and the failure logged\n|===\n\nQueue `VisibilityTimeout` is 180 s against the consumer's 120 s timeout.\n"},{"component":"morpheus","module":"events","path":"events:email-agent-queue.adoc","title":"EmailAgentQueue events","description":"Inbound email events consumed by email-agent, and the one published event with no handler.","url":"https://docx.payability.com/morpheus/events/email-agent-queue.html","navOrder":46,"source":"= EmailAgentQueue events\n:description: Inbound email events consumed by email-agent, and the one published event with no handler.\n\nQueue: `{sam-stack}-email-agent` — a *standard* queue, not FIFO. Consumer:\nxref:lambdas:email-agent.adoc[email-agent].\n\nRouting key: the `event_type` SQS *message attribute*. A record without it logs a warning\nand is skipped.\n\nBecause the queue is standard, there are no message groups, no ordering guarantee, and no\n`MessageDeduplicationId`. Producers use `publish_to_standard_queue()`\n(`lambdas/phone_agent/services/sqs_publisher.py`) rather than\n`morpheus_common.publish_event()`, which sets FIFO-only parameters.\n\n== `Email.IncomingCategorise`\n\nModel: `IncomingCategoriseEvent` (`lambdas/email_agent/models/incoming_categorise.py`).\nProducer: the Payability web application.\n\n[source,python]\n----\nclass IncomingCategoriseEvent(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\", str_strip_whitespace=True)\n\n    event_type: Literal[\"Email.IncomingCategorise\"] = \"Email.IncomingCategorise\"\n    campaign_id: str                          # required — the campaign's UUID\n\n    contact_email: str | None                 # resolve target via (campaignId, email)\n    email_campaign_target_id: str | None      # or resolve by primary key\n\n    inbound_subject: str = \"\"\n    inbound_text: str                         # required, min_length=1\n\n    variation_id: str | None                  # override; normally read from the target row\n    email_activity_id: str | None             # correlation only\n    email_id: str | None                      # emails row to update alongside\n\n    @model_validator(mode=\"after\")\n    def exactly_one_resolver(self): ...\n----\n\n[IMPORTANT]\n====\n`exactly_one_resolver` requires *exactly one* of `contact_email` or\n`email_campaign_target_id` — not both, not neither. A payload with both is rejected at\nvalidation, before the handler runs.\n====\n\nField notes:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Notes\n\n| `campaign_id` | The campaign you already have in application context. Cross-checked against the resolved target's `campaignId`.\n| `variation_id` | Usually omitted. The handler reads the sent template from `EmailCampaignTarget.variationId`; set this only to override a missing or wrong column (backfill or repair).\n| `email_activity_id` | Id of an inbound email activity row already created elsewhere. For log correlation only — it is not used to resolve the target.\n| `email_id` | Primary key of the `emails` row for this message. When set, `emails.category` is written in the same transaction as the target update.\n| Tenant scope | *Not in the payload.* It comes from `EmailCampaignTarget.subscriptionId` after the handler loads the row. See xref:lambdas:email-agent.adoc#tenancy[tenant safety].\n|===\n\n=== Examples\n\nResolve by email:\n\n[source,json]\n----\n{\n  \"event_type\": \"Email.IncomingCategorise\",\n  \"campaign_id\": \"REPLACE_CAMPAIGN_UUID\",\n  \"contact_email\": \"prospect@example.com\",\n  \"inbound_subject\": \"Re: Your note\",\n  \"email_id\": \"REPLACE_EMAIL_UUID\",\n  \"inbound_text\": \"Yes — can we do a call Tuesday afternoon?\"\n}\n----\n\nResolve by target id:\n\n[source,json]\n----\n{\n  \"event_type\": \"Email.IncomingCategorise\",\n  \"campaign_id\": \"REPLACE_CAMPAIGN_UUID\",\n  \"email_campaign_target_id\": \"REPLACE_EMAIL_CAMPAIGN_TARGET_UUID\",\n  \"inbound_subject\": \"Re: Outreach\",\n  \"inbound_text\": \"Please remove me from your list.\",\n  \"email_id\": \"REPLACE_EMAIL_UUID\",\n  \"email_activity_id\": null\n}\n----\n\nConsumer behaviour and the seven categories:\nxref:business:email-engagement.adoc[Email engagement].\n\n== `Resend.InboundEmail`\n\nModel: `ResendEmailReceivedEvent` (`lambdas/email_agent/models/events.py`). Producer: a\nResend webhook relay.\n\n[source,python]\n----\nclass ResendAttachment(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n    id: str\n    filename: str\n    content_type: str\n    content_disposition: str = \"\"\n    content_id: str = \"\"\n\nclass ResendEmailReceivedData(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n    email_id: str\n    created_at: str\n    from_: str = Field(alias=\"from\")      # \"from\" is a Python keyword\n    to: list[str] = []\n    cc: list[str] = []\n    bcc: list[str] = []\n    message_id: str = \"\"\n    subject: str = \"\"\n    attachments: list[ResendAttachment] = []\n\nclass ResendEmailReceivedEvent(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n    type: str\n    created_at: str\n    data: ResendEmailReceivedData\n----\n\n`extra=\"ignore\"` throughout, so Resend can add fields without breaking validation.\n\nThe handler currently logs the email id, sender, subject, and attachment count, and does\nnothing else. Intended behaviour is listed in its module docstring — see\nxref:lambdas:email-agent.adoc[email-agent].\n\n== `Email.SendInternal`\n\nPublished by `phone-agent` during stage-1 task escalation. *No handler is registered.*\n\n[source,json]\n----\n{\n  \"event_type\": \"Email.SendInternal\",\n  \"subscription_id\": \"b191a905-5d19-4fa0-941b-d8eb81c0c477\",\n  \"task_id\": \"…\",\n  \"sales_rep_email\": \"rep@example.com\",\n  \"sales_rep_name\": \"Example Rep\",\n  \"contact_id\": \"…\",\n  \"due_at\": \"2026-08-19T14:00:00+00:00\",\n  \"notes\": \"…\"\n}\n----\n\nThe dispatcher logs \"No handler registered for SQS event_type\" and deletes the message.\nStage-1 escalation therefore notifies by Slack DM only. To implement it: add a handler and\nmodel, register the key, and the existing producer works unchanged. See\nxref:business:follow-up-tasks.adoc#escalation[escalation ladder].\n\n== Local invocation\n\nSample files under `lambdas/email_agent/events/samples/` are the *inner SQS message\nbody*, not the full Lambda event. Wrap one to invoke locally:\n\n[source,json]\n----\n{\n  \"Records\": [\n    {\n      \"messageId\": \"test-msg-1\",\n      \"eventSource\": \"aws:sqs\",\n      \"body\": \"{ …escaped JSON from a sample file… }\",\n      \"messageAttributes\": {\n        \"event_type\": { \"stringValue\": \"Email.IncomingCategorise\", \"dataType\": \"String\" }\n      }\n    }\n  ]\n}\n----\n\nReplace every `REPLACE_*` placeholder before sending to a real queue.\n\n== Failure handling\n\n`maxReceiveCount: 1` — a handler failure sends the message to\n`{sam-stack}-email-agent-dlq`, retained 14 days. Queue `VisibilityTimeout` is 180 s\nagainst the consumer's 120 s timeout.\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Failure | Fate\n\n| Missing `event_type` attribute | skipped, deleted\n| Unregistered `event_type` | skipped, deleted\n| Malformed JSON body | DLQ\n| `ValidationError` — including both or neither resolver | DLQ\n| Tenant mismatch, missing row, bad UUID | deleted (the handler logs and returns)\n| Bedrock error, or the model returning no structured output | DLQ\n|===\n"},{"component":"morpheus","module":"events","path":"events:scheduled.adoc","title":"Scheduled (EventBridge) events","description":"How scheduled triggers carry a routing key, and every detail-type in the platform.","url":"https://docx.payability.com/morpheus/events/scheduled.html","navOrder":47,"source":"= Scheduled (EventBridge) events\n:description: How scheduled triggers carry a routing key, and every detail-type in the platform.\n\nScheduled work is dispatched by exactly the same mechanism as queue work. The difference\nis only where the routing key comes from: an EventBridge rule's `Input` override supplies\na `detail-type` at the event root.\n\n== The Input pattern\n\n[source,yaml]\n----\nEvents:\n  EmailClickReEngagement:\n    Type: Schedule\n    Properties:\n      Name: !Sub \"${AWS::StackName}-email-click-reengagement\"\n      Description: \"Re-engage contacts who clicked emails\"\n      Schedule: rate(6 hours)\n      Enabled: true\n      Input: '{\"detail-type\":\"Hubspot.EmailClickReEngagement\",\"source\":\"morpheus.hubspot\",\"detail\":{}}'\n----\n\n`Input` *replaces* the whole event body, so the delivered event is exactly that JSON — none\nof EventBridge's usual envelope fields (`version`, `id`, `account`, `region`, `time`) are\npresent.\n\n[IMPORTANT]\n====\nThe `detail-type` string must match a key in the function's registry *exactly*. A\nmismatch produces \"No handler registered for detail-type\" and the invocation returns\nsuccessfully having done nothing — a silent failure. This is the most common mistake when\nadding a schedule.\n====\n\n== `ScheduledEvent`\n\nEach function defines its own, all identical:\n\n[source,python]\n----\nclass ScheduledEvent(BaseModel):\n    \"\"\"Generic EventBridge scheduled-rule event.\"\"\"\n    model_config = ConfigDict(extra=\"ignore\")\n    source: str = \"\"\n----\n\n`extra=\"ignore\"` tolerates the whole envelope, and the only declared field is optional. A\nscheduled event carries no payload, so there is nothing to share between functions and\nnothing to validate — the model exists purely so the dispatcher's `model_validate()` call\nis uniform.\n\nTwo scraper events are richer, because they *do* carry parameters:\n\n[source,python]\n----\nclass SellerDiscoveryDetail(BaseModel):\n    model_config = ConfigDict(extra=\"allow\")\n    marketplace: str = \"amazon\"\n    provider: str = \"smartscout\"\n    max_pages: int | None = None\n\nclass SellerDiscoveryEvent(BaseModel):\n    model_config = ConfigDict(populate_by_name=True)\n    detail_type: str = Field(alias=\"detail-type\")\n    source: str\n    id: str | None = None            # optional: an Input override omits the envelope\n    version: str | None = None\n    account: str | None = None\n    region: str | None = None\n    detail: SellerDiscoveryDetail = Field(default_factory=SellerDiscoveryDetail)\n----\n\nEvery `detail` field defaults, so the rule's `Input` need only specify what differs.\n\n== The catalogue\n\n=== enrichment-scraper\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Model | Purpose\n\n| `SellerDiscovery.Amazon` | `SellerDiscoveryEvent` | Paginated SmartScout discovery — xref:business:seller-discovery.adoc[rules]\n| `SellerDiscovery.Walmart` | `SellerDiscoveryEvent` | Stub, raises `NotImplementedError`\n| `SellerDiscovery.RunCategory` | `RunCategoryEvent` | Stub, ad-hoc per-category discovery\n| `Scraper.BigQueryContactSync` | `ScheduledEvent` | Incremental BigQuery contact mirror\n|===\n\n=== enrichment-scorer\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Model | Purpose\n\n| `Seller.EnrichPending` | `ScheduledEvent` | Enrich up to 10 sellers with `lifeCycleStage = new` and `enrichedAt IS NULL`\n| `Seller.ScorePending` | `ScheduledEvent` | Score up to 10 enriched sellers with `payoutPainScore IS NULL`\n|===\n\n=== morpheus-hubspot\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Model | Purpose\n\n| `Hubspot.FollowUpTaskPoll` | `ScheduledEvent` | Poll HubSpot for completed tasks — xref:business:follow-up-tasks.adoc[rules]\n| `Hubspot.ContactResolve` | `ScheduledEvent` | Wide-search HubSpot id resolution — xref:business:crm-sync.adoc[rules]\n| `Hubspot.BigQueryEngagementSync` | `ScheduledEvent` | Mirror engagements into `Activity`\n| `Hubspot.EmailClickReEngagement` | `ScheduledEvent` | xref:business:re-engagement.adoc#email-click[Email click segment]\n| `Hubspot.SupplierAccountReEngagement` | `ScheduledEvent` | xref:business:re-engagement.adoc#supplier[Supplier segment]\n| `Hubspot.UnattendedMeetingReEngagement` | `ScheduledEvent` | xref:business:re-engagement.adoc#no-show[No-show segment]\n| `Hubspot.LostDealReEngagement` | `ScheduledEvent` | xref:business:re-engagement.adoc#lost-deal[Lost deal segment]\n| `Hubspot.SalesRepCallListRefresh` | `ScheduledEvent` | xref:business:call-lists.adoc#materialisation[Rebuild per-rep lists]\n| `Hubspot.Five9CallSync` | `ScheduledEvent` | xref:business:call-logging.adoc[Import Five9 CSV logs]\n|===\n\n=== phone-agent\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| `detail-type` | Model | Purpose\n\n| `Phone.LineIntelPending` | `ScheduledEvent` | xref:business:phone-intelligence.adoc[Twilio line intelligence]\n| `Phone.TaskDeadlineReminder` | `ScheduledEvent` | xref:business:follow-up-tasks.adoc#escalation[Slack reminders + escalation]\n| `Phone.CampaignTargetReEngagement` | `ScheduledEvent` | xref:business:re-engagement.adoc#campaign[Campaign segment]\n|===\n\n== Dispatch and failure\n\nThe scheduled path differs from the SQS path in one important way: *failures propagate*.\n\n[source,python]\n----\ndef _handle_eventbridge(event, detail_type, context) -> None:\n    registration = event_registry.lookup(detail_type)\n    if registration is None:\n        logger.warning(\"No handler registered for detail-type %r. Registered types: %s\", …)\n        return                                   # silent no-op\n\n    try:\n        parsed = registration.event_model.model_validate(event)\n    except ValidationError as exc:\n        logger.error(\"Event validation failed … Raw event (truncated): %s\", json.dumps(event)[:500])\n        raise                                    # propagate\n\n    try:\n        registration.handler_class().handle(parsed, context)\n    except Exception:\n        logger.error(\"Unhandled exception in handler %s:\\n%s\", …, traceback.format_exc())\n        raise                                    # propagate\n----\n\nThere is no queue to absorb a failure, so raising is what makes it visible in the\nfunction's error metric. The next tick is the retry.\n\nMost scheduled handlers iterate per row with an inner `try`/`except`, so one bad row does\nnot abort the batch — the exception path is for setup failures, not row failures.\n\n== Common early returns\n\nScheduled handlers return early, with a warning and no work, when a precondition is\nmissing:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Condition | Handlers affected\n\n| `BIGQUERY_CREDENTIALS` empty | `Hubspot.*ReEngagement` (three), `Hubspot.BigQueryEngagementSync`, `Scraper.BigQueryContactSync`, `Phone.CampaignTargetReEngagement`\n| The BigQuery query raised | all BigQuery-backed handlers — logged at `ERROR`, then return\n| The view returned no rows | all segment handlers\n| `CRM_SYNC_QUEUE_URL` empty | `Phone.LineIntelPending`, `Phone.CampaignTargetReEngagement`\n| `SLACK_BOT_TOKEN` or `SALES_SLACK_CHANNEL_ID` empty | `Phone.TaskDeadlineReminder`\n| `enrichment.amazon.disabled = \"true\"` in `configs` | `SellerDiscovery.Amazon`\n|===\n\nAn early return is *not* an error. It logs at `WARNING` or `INFO` and the invocation\nsucceeds — which is why a handler that silently does nothing is best diagnosed by reading\nits log line rather than its error metric.\n\n== Invoking a scheduled handler manually\n\n[source,bash,subs=attributes+]\n----\naws lambda invoke \\\n  --function-name {sam-stack}-hubspot-sync \\\n  --payload '{\"detail-type\":\"Hubspot.ContactResolve\",\"source\":\"morpheus.hubspot\",\"detail\":{}}' \\\n  --cli-binary-format raw-in-base64-out /dev/stdout\n----\n\nFor `SellerDiscovery.Amazon`, add parameters in `detail`:\n\n[source,json]\n----\n{\"detail-type\":\"SellerDiscovery.Amazon\",\"source\":\"enrichment.scraper\",\n \"detail\":{\"marketplace\":\"amazon\",\"provider\":\"smartscout\",\"max_pages\":5}}\n----\n\n[CAUTION]\n====\nA manual invocation is production traffic. The re-engagement handlers PATCH live HubSpot\ncontacts; the Five9 sync creates HubSpot call engagements. Prefer a narrow payload (for\nexample `Contact.ResolveHubSpotIds` with `batch_limit: 1`) when testing.\n====\n\n== Cadences\n\nLive schedule expressions, which rule is enabled, and which are commented out:\nxref:infrastructure:schedules.adoc[EventBridge schedules].\n"},{"component":"morpheus","module":"events","path":"events:hubspot-webhooks.adoc","title":"HubSpot webhooks","description":"How HubSpot-side changes reach Morpheus, and the ordered processor registry that handles them.","url":"https://docx.payability.com/morpheus/events/hubspot-webhooks.html","navOrder":48,"source":"= HubSpot webhooks\n:description: How HubSpot-side changes reach Morpheus, and the ordered processor registry that handles them.\n\nChanges made by people *in* HubSpot — a rep editing a contact, someone clicking an\nunsubscribe link — need to flow back into Morpheus. HubSpot delivers those as v3 webhook\nnotifications, which an external forwarder wraps into a `Crm.HubSpotWebhook` message on\n`CrmSyncQueue`.\n\n[mermaid]\n....\nflowchart LR\n  HS([\"HubSpot portal\"]) -->|v3 webhook| FWD([\"forwarder\"])\n  FWD -->|Crm.HubSpotWebhook| Q[[\"CrmSyncQueue.fifo\"]]\n  Q --> H[\"CrmHubSpotWebhookHandler\"]\n  H --> R[\"WebhookProcessorRegistry\\nfirst match wins\"]\n  R --> P1[\"NewsletterOptOutProcessor\"]\n  P1 --> DB[(\"target_emails\")]\n....\n\nThe forwarder is not part of this repository. Morpheus only consumes the wrapped event.\n\n== Envelope\n\nOne message carries a *batch* of inner notifications. Inner events arrive in HubSpot's\ncamelCase and are accepted in either case via `AliasChoices`.\n\n[source,json]\n----\n{\n  \"event_type\": \"Crm.HubSpotWebhook\",\n  \"source\": \"hubspot-forwarder\",\n  \"portalId\": 12345678,\n  \"events\": [\n    {\n      \"subscriptionType\": \"contact.propertyChange\",\n      \"objectId\": 987654321,\n      \"occurredAt\": 1713628800000,\n      \"propertyName\": \"hs_email_optout_44467216\",\n      \"propertyValue\": \"true\"\n    }\n  ]\n}\n----\n\nFull model: xref:crm-sync-queue.adoc[CrmSyncQueue events]. `events` requires at least one\nelement (`min_length=1`), and `objectId` accepts a numeric string as well as an integer.\n\n== Handler\n\n`CrmHubSpotWebhookHandler` opens *one* database session and *one* CRM client for the whole\nbatch, then dispatches each inner event:\n\n[source,python]\n----\nwith get_session() as session:\n    for inner in event.events:\n        _registry.dispatch(\n            session=session, client=client,\n            subscription_id=subscription_id, inner=inner,\n        )\n----\n\nOne session for the batch means one commit for the batch. A processor that raises rolls\nback the whole message — which is why processors are written to log and return rather than\nraise.\n\n== The processor registry\n\n`WebhookProcessorRegistry` walks its processors in *declaration order* and calls the\n*first* whose `applies_to(inner)` returns `True`, then stops. That prevents two processors\nmutating the same row or double-counting a side effect.\n\nNothing matching logs at `DEBUG` and returns — no exception — so an unknown webhook shape\ncannot poison the queue.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Processor | Matches\n\n| `NewsletterOptOutProcessor` | `subscription_type == \"contact.propertyChange\"` and `property_name` matching `^hs_email_optout_(\\d+)$` where the captured id equals `44467216`\n|===\n\nThe registry accepts an injected tuple, which is how tests supply a custom chain.\n\n== The processor contract\n\n[source,python]\n----\nclass HubSpotWebhookProcessor(ABC):\n    @abstractmethod\n    def applies_to(self, inner: HubSpotWebhookInnerEvent) -> bool: ...\n\n    @abstractmethod\n    def process(self, *, session: Session, client: HubSpot,\n                subscription_id: str, inner: HubSpotWebhookInnerEvent) -> None: ...\n----\n\nRules, from the registry's own documentation:\n\n* Match *tightly* in `applies_to()` — on `subscription_type`, `property_name`, and any\n  discriminating value. A loose matcher silently swallows events a later processor should\n  have handled.\n* Read from HubSpot with `client` if you need more than the notification carries; persist\n  with `session` and the data-layer repositories.\n* *Never commit inside a processor.* The handler's `get_session()` block owns the\n  transaction.\n* Never log full payloads or raw PII.\n* An unsupported shape must log and return, not raise.\n* Order matters — put specific matchers before generic ones when both could match.\n\n== `NewsletterOptOutProcessor`\n\nThe one production processor, and a good model for a new one.\n\n[source,python]\n----\n_OPTOUT_PROPERTY = re.compile(r\"^hs_email_optout_(\\d+)$\")\n\ndef applies_to(self, inner) -> bool:\n    if inner.subscription_type != \"contact.propertyChange\":\n        return False\n    m = _OPTOUT_PROPERTY.match(inner.property_name or \"\")\n    if not m:\n        return False\n    return int(m.group(1)) == HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID    # 44467216\n----\n\n`process()` then:\n\n. Interprets `property_value`: `\"true\"` → opted out, `\"false\"` or missing/blank →\n  resubscribed. HubSpot may omit or clear the value when the contact is no longer opted\n  out, so blank is deliberately treated as `false`. Anything else logs a warning and\n  returns.\n. Converts `occurred_at` (epoch milliseconds) to a UTC datetime.\n. Reads the contact's `email` from the CRM by `object_id`. A 404 logs a warning and\n  returns; any other `ApiException` is re-raised.\n. Parses `subscription_id`; an invalid UUID logs at `ERROR` and returns.\n. Calls `TargetEmailsRepository.set_newsletter_opt_out()`, which writes\n  `newsletterOptOut` and `newsletterOptOutAt` — *newsletter only*, not the other two\n  opt-out flags — and refuses to move `newsletterOptOutAt` backwards, so an out-of-order\n  webhook delivery cannot undo a newer state.\n. No matching `target_emails` row logs at `INFO` and returns.\n\nEvery failure path is a log-and-return. That is what keeps one bad notification from\nrolling back the rest of the batch.\n\n== Adding a processor\n\n. Create a module under `lambdas/morpheus_hubspot/events/webhook_processors/` subclassing\n  `HubSpotWebhookProcessor`.\n. Implement `applies_to()` with a tight matcher.\n. Implement `process()` — read via `client`, write via `session` and the repositories, and\n  do not commit.\n. Append an instance to the default tuple in `WebhookProcessorRegistry.__init__`, in the\n  right order.\n. Optionally export it from `webhook_processors/__init__.py`.\n. Add a test asserting both a match and a near-miss — the near-miss is what proves the\n  matcher is tight.\n\nThere is no registry entry to add and no template change: the `Crm.HubSpotWebhook` event\ntype is already registered, and processors are internal to it.\n\n== Which HubSpot subscriptions to configure\n\nThe forwarder decides what HubSpot sends. For the current processor the portal needs a\n`contact.propertyChange` subscription covering `hs_email_optout_44467216`. Adding a\nprocessor generally means adding a matching HubSpot subscription too — otherwise the\nprocessor never fires.\n\n== Testing\n\n[source,bash]\n----\ncd lambdas && uv run pytest morpheus_hubspot/tests/test_newsletter_opt_out_processor.py -v\n----\n\nSample payload:\n`lambdas/morpheus_hubspot/events/samples/crm_hubspot_webhook_newsletter_optout.json`.\n"},{"component":"morpheus","module":"events","path":"events:publishing.adoc","title":"Publishing events","description":"publish_event(), publish_events(), FIFO message groups and deduplication, and the standard-queue publisher.","url":"https://docx.payability.com/morpheus/events/publishing.html","navOrder":49,"source":"= Publishing events\n:description: publish_event(), publish_events(), FIFO message groups and deduplication, and the standard-queue publisher.\n\n== `publish_event()`\n\n`libs/morpheus-common/morpheus_common/events/publisher.py` — the shared publisher for the\ntwo FIFO queues.\n\n[source,python]\n----\ndef publish_event(\n    *,\n    queue_url: str,\n    event: BaseModel,\n    group_id: str,\n    dedup_id: str | None = None,\n) -> None:\n----\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Argument | Meaning\n\n| `queue_url` | Full SQS queue URL, from the function's `Settings`\n| `event` | Any Pydantic model — serialised with `model_dump_json()`\n| `group_id` | FIFO `MessageGroupId`. Messages in one group are ordered and deduplicated within a five-minute window.\n| `dedup_id` | FIFO `MessageDeduplicationId`. Defaults to `sha256` of the serialised body.\n|===\n\nThe boto3 SQS client is a module-level singleton pinned to `us-east-1`. Publishing logs at\n`DEBUG` with the event type, group id, and queue — never the body.\n\nUsage:\n\n[source,python]\n----\nfrom morpheus_common.events import ContactUpdateEvent, publish_event\n\npublish_event(\n    queue_url=settings.crm_sync_queue_url,\n    event=ContactUpdateEvent(\n        subscription_id=str(settings.subscription_id),\n        hubspot_contact_id=hubspot_cid,\n        properties={\"call_list_date\": today, \"call_priority\": \"4\"},\n    ),\n    group_id=hubspot_cid,\n)\n----\n\nBecause `event_type` defaults on the model, constructing the model is enough — the routing\nkey cannot drift from the registry key without someone editing the default.\n\n== Choosing a `group_id`\n\nFIFO ordering is *per group*, and a group is processed serially. The choice is therefore a\nthroughput decision as much as a correctness one.\n\n[cols=\"2,2,3\",options=\"header\"]\n|===\n| Producer | `group_id` | Why\n\n| enrichment-scraper → `SellerRecord.Received` | `seller_id` | One group per seller. Grouping by marketplace would cap the consumer at two concurrent invocations regardless of queue depth.\n| enrichment-scorer → `Seller.CrmSync` | `seller_id` | Two syncs for one seller must not interleave; different sellers are independent.\n| phone-agent → `Contact.Update` (line intel) | `target_phone_number_id` | One group per phone row.\n| phone-agent → `Contact.Update` (campaign) | `hubspot_contact_id` | Serialises updates to one contact; parallel across contacts.\n| phone-agent → `FollowUpTask.Sync` | `task_id` | One group per task.\n|===\n\nThe rule of thumb: group by the *entity being mutated*. That gives ordering where it\nmatters and parallelism everywhere else.\n\n== Choosing a `dedup_id`\n\nOmitting it hashes the serialised body, which deduplicates *identical* messages. That is\nright when payload equality means \"the same event\".\n\nSupply one explicitly when identity is narrower than the payload:\n\n[source,python]\n----\npublish_event(\n    queue_url=settings.crm_sync_queue_url,\n    event=crm_event,\n    group_id=seller_id,\n    dedup_id=hashlib.sha256(f\"crm-sync-{seller_id}\".encode()).hexdigest(),\n)\n----\n\nHere the dedup id keys on *seller identity alone*, so two enrichment passes that produced\nslightly different results inside the five-minute window collapse to one CRM sync. The\nalternative — a body hash — would let both through.\n\n`ContentBasedDeduplication` is `false` on both FIFO queues, so an explicit or\ndefault-computed id is always required.\n\n`QueueService` in the scraper builds its own entries for `send_message_batch` rather than\nusing `publish_event()`, because batching is what makes 10 messages one API call. It uses\n`sha256(seller_id + marketplace)`. New producers should reach for `publish_events()` instead\nof hand-rolling batch entries.\n\n== `publish_events()`\n\nThe batched counterpart, for producers with more than a handful of messages. It chunks into\n`SendMessageBatch` calls of ten (the SQS maximum), so a thousand events cost a hundred API\ncalls rather than a thousand.\n\n[source,python]\n----\ndef publish_events(\n    *,\n    queue_url: str,\n    events: Sequence[OutboundEvent],\n) -> list[str]:\n----\n\n`OutboundEvent` is a `NamedTuple` of `group_id`, `event` and an optional `dedup_id`, so the\nper-message semantics match `publish_event()` exactly — including the default body hash.\n\nFailures are *returned, not raised*: the result is the `group_id` of every event that did not\nmake it, whether SQS rejected the individual entry or the whole chunk errored. One bad chunk\ntherefore cannot abort the rest of the batch. Callers should log the count and move on.\n\n[source,python]\n----\nfrom morpheus_common.events import ContactUpdateEvent, OutboundEvent, publish_events\n\noutbound = [\n    OutboundEvent(\n        group_id=hubspot_cid,\n        event=ContactUpdateEvent(...),\n    )\n    for hubspot_cid in targets\n]\n\nfailed = publish_events(queue_url=settings.crm_sync_queue_url, events=outbound)\nlogger.info(\"published=%d failed=%d\", len(outbound) - len(failed), len(failed))\n----\n\n[IMPORTANT]\n====\nPrefer `publish_events()` over calling `publish_event()` in a loop whenever the row count is\nunbounded. A scheduled Lambda publishing per row spends one network round trip per record,\nwhich is a common cause of hitting the function timeout part-way through a batch — silently\ndropping whichever records happen to sort last.\n====\n\n== Standard queues\n\n`EmailAgentQueue` is a standard queue, so `MessageGroupId` and\n`MessageDeduplicationId` are invalid and would be rejected. `phone-agent` has a separate\npublisher for it:\n\n[source,python]\n----\nfrom services.sqs_publisher import publish_to_standard_queue\n\npublish_to_standard_queue(\n    queue_url=settings.email_agent_queue_url,\n    body={\"event_type\": \"Email.SendInternal\", \"task_id\": str(task.id), …},\n    event_type=\"Email.SendInternal\",\n)\n----\n\nIt sets the `event_type` *message attribute*, which is where the email-agent dispatcher\nreads the routing key.\n\n[IMPORTANT]\n====\nWhich queue a producer targets determines which publisher to use. Using\n`publish_event()` against `EmailAgentQueue` fails; using `publish_to_standard_queue()`\nagainst a FIFO queue fails for the opposite reason.\n====\n\n== Message attribute versus body field\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Queue | Routing key location\n\n| `EnrichmentPipelineQueue.fifo` | `event_type` message attribute\n| `EmailAgentQueue` | `event_type` message attribute\n| `CrmSyncQueue.fifo` | `event_type` field *inside the body*\n|===\n\n`CrmSyncQueue` reads from the body, which is what allows a message body to be a JSON\n*array* of events dispatched individually — an array has no single attribute to route on.\n\nProducers should set both where practical: the models carry `event_type` regardless, so a\nmessage published with `publish_event()` always has it in the body.\n\n== Fire-and-forget publishing\n\nWhere a publish is not the point of the operation, it is best-effort:\n\n[source,python]\n----\ntry:\n    publish_event(queue_url=..., event=event, group_id=seller_id, dedup_id=...)\nexcept Exception:\n    logger.warning(\n        \"Failed to publish CRM sync event sellerId=%r — enrichment DB write was committed\",\n        seller_id, exc_info=True,\n    )\n----\n\nThe database write already committed; failing the message would repeat expensive AI work to\nretry a cheap publish. Campaign re-engagement gets the same tolerance from `publish_events()`,\nwhich reports failed group ids instead of raising, so one rejected entry does not stop the\nrest of the batch.\n\nContrast that with `Phone.LineIntelPending`, which returns *early* with an `ERROR` when\n`CRM_SYNC_QUEUE_URL` is unset — there the publish is the whole point of the job.\n\n== Publishing from outside this repository\n\nThe Payability web application publishes most `CrmSyncQueue` events. It must match the same\ncontract:\n\n* `event_type` in the body, matching a key in\n  `lambdas/morpheus_hubspot/registry.py#event_registry`.\n* A body that validates against the corresponding Pydantic model in\n  `morpheus_common.events.models`.\n* `subscription_id` matching the consumer's configured tenant, where the model has one.\n* A `MessageGroupId` — the entity being mutated — and a `MessageDeduplicationId`.\n\nQueue URLs come from the SAM stack outputs (`CrmSyncQueueUrl`). The `EmailAgentQueue`\noutputs are commented out, so read that URL with\n`aws sqs get-queue-url --queue-name {sam-stack}-email-agent`.\n"},{"component":"morpheus","module":"events","path":"events:adding-an-event.adoc","title":"Adding a new event","description":"The five steps to add an event type to a Lambda function, with a worked example.","url":"https://docx.payability.com/morpheus/events/adding-an-event.html","navOrder":50,"source":"= Adding a new event\n:description: The five steps to add an event type to a Lambda function, with a worked example.\n\nAdding an event never means editing `handler.py`. Five steps: model, handler, registry,\ntrigger, sample.\n\n== 1. Define the model\n\n*Cross-function event* — a producer in one function or the web application, a consumer in\nanother. Add it to `libs/morpheus-common/morpheus_common/events/models.py` and export it\nfrom `events/__init__.py` and `morpheus_common/__init__.py`.\n\n*Function-local event* — no other component constructs it. Put it in that function's\n`models/`.\n\n[source,python]\n----\nclass ContactArchiveEvent(BaseModel):\n    \"\"\"Archive a HubSpot contact and mark the local row inactive.\"\"\"\n\n    model_config = ConfigDict(str_strip_whitespace=True)\n\n    event_type: str = \"Contact.Archive\"          # default = the routing key\n    subscription_id: str\n    hubspot_contact_id: str = Field(..., min_length=1)\n    reason: str | None = None\n\n    @model_validator(mode=\"after\")\n    def _require_reason_when_permanent(self) -> \"ContactArchiveEvent\":\n        ...\n        return self\n----\n\nConventions:\n\n* `event_type` defaults to the routing key, so a producer gets it for free.\n* `ConfigDict(str_strip_whitespace=True)` for domain events;\n  `ConfigDict(extra=\"ignore\")` for anything mirroring an external payload.\n* Cross-field requirements go in a `model_validator(mode=\"after\")` — rejected at the\n  boundary, never mid-handler.\n* Field descriptions matter; they are the contract a producer reads.\n\n== 2. Write the handler\n\n`events/<name>.py`, extending the function's base class.\n\n[source,python]\n----\n\"\"\"Handler for ``Contact.Archive`` — archive a HubSpot contact.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\nfrom morpheus_common.events.models import ContactArchiveEvent\nfrom morpheus_data_layer.repositories import ContactRepository\nfrom morpheus_data_layer.session import get_session\n\nfrom events.base import BaseEventHandler\nfrom models.config import settings\nfrom models.secrets import load_secrets\nfrom utils.hubspot_client import get_hubspot_client\n\nlogger = logging.getLogger(__name__)\n\n_contact_repo = ContactRepository()          # module-level; repositories are stateless\n\n\nclass ContactArchiveHandler(BaseEventHandler):\n    def handle(self, event: ContactArchiveEvent, context: Any) -> None:\n        _ = context\n\n        if event.subscription_id != str(settings.subscription_id):\n            logger.error(\n                \"Contact.Archive subscription mismatch event=%r lambda=%r\",\n                event.subscription_id, str(settings.subscription_id),\n            )\n            return                                    # bad data → drop, do not retry\n\n        secrets = load_secrets()\n        client = get_hubspot_client(secrets)\n\n        archive_contact(client, event.hubspot_contact_id)   # raises → DLQ\n\n        with get_session() as session:\n            _contact_repo.update_by_hubspot_id(\n                session, event.hubspot_contact_id, {\"isActive\": False},\n            )\n\n        logger.info(\n            \"Contact.Archive completed hubspot_contact_id=%r reason=%r\",\n            event.hubspot_contact_id, event.reason,\n        )\n----\n\nThe rules this example follows:\n\n* *Guard first.* Tenant mismatch, missing identifiers, empty payloads — log at `ERROR` and\n  `return`. The message is deleted; retrying cannot fix bad data.\n* *Let transient failures raise.* A HubSpot 5xx or a database error propagates so the\n  message reaches the DLQ.\n* *Repositories at module level*, sessions in the handler. Repositories never open sessions\n  and never commit.\n* *`get_session()` owns the transaction*, and it commits on clean exit.\n* *No HubSpot SDK imports.* Every CRM call goes through `pyb_hubspot`.\n* *End with a summary line* so one log line tells the whole story.\n* *Log identifiers, not payloads.*\n\n=== Multi-row handlers\n\nFor a handler that iterates, wrap each row so one failure does not abort the batch, and\ncount outcomes:\n\n[source,python]\n----\nupdated = skipped = 0\nfor row in rows:\n    try:\n        ...\n        updated += 1\n    except Exception:\n        logger.error(\"… failed id=%r\", row[\"id\"], exc_info=True)\n        skipped += 1\nlogger.info(\"Job completed updated=%d skipped=%d total=%d\", updated, skipped, len(rows))\n----\n\n== 3. Register the routing key\n\nDataclass registry (scraper, scorer, email-agent, phone-agent):\n\n[source,python]\n----\ndef build_registry() -> EventRegistry:\n    registry = EventRegistry()\n    registry.register(\"Contact.Archive\", ContactArchiveHandler, ContactArchiveEvent)\n    return registry\n----\n\nDict registry (morpheus-hubspot):\n\n[source,python]\n----\nevent_registry: dict = {\n    …\n    \"Contact.Archive\": (ContactArchiveHandler(), ContactArchiveEvent),\n}\n----\n\nScheduled handlers in `morpheus-hubspot` go in `scheduled_registry` instead, keyed by\n`detail-type`.\n\n[IMPORTANT]\n====\nThe registry key is the wire contract. It must match the `event_type` a producer sends, or\nthe `detail-type` in a rule's `Input`, *exactly*. A mismatch is a silent no-op: the\ndispatcher logs \"no handler registered\" and the invocation succeeds.\n====\n\n== 4. Add the trigger\n\n*SQS* — the queue trigger already exists on the function; nothing to add. The producer sets\nthe routing key.\n\n*Scheduled* — add an `Events` entry in `lambdas/template.yaml`:\n\n[source,yaml]\n----\nEvents:\n  ContactArchiveSweep:\n    Type: Schedule\n    Properties:\n      Name: !Sub \"${AWS::StackName}-contact-archive-sweep\"\n      Description: \"Archive contacts flagged for removal — daily\"\n      Schedule: cron(0 5 * * ? *)\n      Enabled: true\n      Input: '{\"detail-type\":\"Contact.ArchiveSweep\",\"source\":\"morpheus.hubspot\",\"detail\":{}}'\n----\n\n*New queue* — define the queue, a dead-letter queue, and the `Events` entry, keeping the\nfunction `Timeout` below the queue's `VisibilityTimeout`. See\nxref:infrastructure:queues.adoc[Queues].\n\nCheck the layer list too: a handler needing database access requires\n`MorpheusDataLayer`, an agent requires `MorpheusLlmLayer`, HubSpot requires\n`PybHubspotLayer`, BigQuery requires `MorpheusBigQueryLayer`.\n\n== 5. Add a sample and a test\n\nSample payload in `<function>/events/samples/<name>.json` — the *inner message body*, not\nthe full Lambda event. Use `REPLACE_*` placeholders for ids.\n\n[source,json]\n----\n{\n  \"event_type\": \"Contact.Archive\",\n  \"subscription_id\": \"b191a905-5d19-4fa0-941b-d8eb81c0c477\",\n  \"hubspot_contact_id\": \"REPLACE_HUBSPOT_CONTACT_ID\",\n  \"reason\": \"duplicate record\"\n}\n----\n\nThen a test. Highest-value targets first: the decision functions, the guard clauses, and\nthe model validators. See xref:lambdas:testing.adoc[Testing Lambdas].\n\n== 6. Document it\n\n* Add a row to xref:catalog.adoc[Event catalog].\n* Add the schema to the relevant queue page, or a row to\n  xref:scheduled.adoc[Scheduled events].\n* If it encodes a business rule, add or extend a page under\n  xref:business:index.adoc[Business logic].\n* If it adds a schedule, update xref:infrastructure:schedules.adoc[EventBridge schedules].\n\n== Deploy\n\n[source,bash]\n----\ncd lambdas\nmake deploy-hubspot        # re-exports requirements.txt, builds, deploys\n----\n\nThen verify:\n\n[source,bash,subs=attributes+]\n----\naws lambda invoke \\\n  --function-name {sam-stack}-hubspot-sync \\\n  --payload \"$(cat morpheus_hubspot/events/samples/contact_archive.json | jq -c '{Records:[{messageId:\"t1\",eventSource:\"aws:sqs\",body:(.|tostring)}]}')\" \\\n  --cli-binary-format raw-in-base64-out /dev/stdout\n----\n\n== Checklist\n\n* [ ] Model defined, with validators, in the right place\n* [ ] Model exported from `__init__.py` if it is cross-function\n* [ ] Handler extends the function's base class\n* [ ] Guards return without raising; transient failures raise\n* [ ] Repositories at module level; `get_session()` owns the transaction\n* [ ] Registered with a key matching the wire contract exactly\n* [ ] Trigger added, with `Timeout < VisibilityTimeout` if it is a new queue\n* [ ] Required layers present on the function\n* [ ] Sample payload added\n* [ ] Test covering the decision logic\n* [ ] Documentation updated\n"},{"component":"morpheus","module":"libs","path":"libs:index.adoc","title":"Shared libraries","description":"The five Python libraries under libs/, packaged as Lambda layers.","url":"https://docx.payability.com/morpheus/libs/index.html","navOrder":51,"source":"= Shared libraries\n:description: The five Python libraries under libs/, packaged as Lambda layers.\n\nFive libraries live in `libs/`. Each is an independent `uv` project with its own\n`pyproject.toml`, and each is packaged as a Lambda layer by the SAM template.\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Library | Import prefix | Layer | Purpose\n\n| xref:morpheus-common.adoc[morpheus-common]\n| `morpheus_common`\n| `MorpheusCommonLayer`\n| Event models, the SQS publisher, the cached secrets helper, Sentry\n\n| xref:morpheus-llm.adoc[morpheus-llm]\n| `morpheus_llm`\n| `MorpheusLlmLayer`\n| Strands agent services, Bedrock, MCP tools, model ids\n\n| xref:morpheus-data-layer.adoc[morpheus-data-layer]\n| `morpheus_data_layer`\n| `MorpheusDataLayer`\n| SQLAlchemy models, repositories, session management\n\n| xref:morpheus-bigquery.adoc[morpheus-bigquery]\n| `morpheus_bigquery`\n| `MorpheusBigQueryLayer`\n| BigQuery client factory\n\n| xref:pyb-hubspot.adoc[pyb-hubspot]\n| `pyb_hubspot`\n| `PybHubspotLayer`\n| The HubSpot client and every HubSpot API call\n|===\n\n== Who uses what\n\n[cols=\"2,1,1,1,1,1\",options=\"header\"]\n|===\n| Consumer | common | llm | data-layer | bigquery | pyb-hubspot\n\n| enrichment-scraper | ✓ | | ✓ | ✓ |\n| enrichment-scorer | ✓ | ✓ | ✓ | | ✓\n| morpheus-hubspot | ✓ | | ✓ | ✓ | ✓\n| email-agent | ✓ | ✓ | ✓ | |\n| phone-agent | ✓ | | ✓ | ✓ |\n| agentcore runtime | | ✓ | | |\n|===\n\n`MorpheusCommonLayer` is always first in a function's layer list — it provides Sentry,\nwhich every handler configures at cold start.\n\nThe AgentCore runtime depends on `morpheus-llm` as an *editable path dependency*\n(`[tool.uv.sources]` in `agentcore/pyproject.toml`), not as a layer, since it is a\ncontainer rather than a Lambda.\n\n== Design rules\n\nLibraries never read the environment::\n`create_hubspot_mcp()`, `create_tavily_mcp()`, `create_bigquery_client()`, and\n`HubSpotSyncClient.from_*()` all take credentials as explicit keyword arguments. The\ncaller fetches them from its own secrets loader. This keeps credential provenance visible\nat the call site and makes the library testable without environment manipulation.\n\nLibraries never open transactions::\n`morpheus_data_layer` repositories require a `Session` argument and never commit. The\ncaller owns the transaction boundary via `get_session()`.\n\nOne responsibility each::\n`pyb_hubspot` owns every HubSpot API call — no consumer imports `hubspot.*` directly.\n`morpheus_llm` owns every Bedrock and Strands construction. That is what makes rate-limit\nhandling, auth modes, and prompt caching changeable in one place.\n\nLazy imports where the dependency is heavy::\n`morpheus_llm/__init__.py` uses PEP 562 `__getattr__`, so `import morpheus_llm.utils`\ndoes not pull in Bedrock, Strands, or `bedrock_agentcore`. Consumers additionally defer\nimports inside `@lru_cache` functions so modules stay importable without the layer\ninstalled.\n\nCross-library dependencies are allowed, cross-*unit* imports are not::\n`pyb_hubspot` imports `morpheus_common.events.models` for property mapping, and\n`morpheus_llm.utils.hubspot` re-exports `pyb_hubspot.auth.refresh_hubspot_access_token`.\nBoth are fine. What is prohibited is `agentcore/`, `lambdas/`, and `libs/` importing each\nother's *application* code.\n\n== Dependency management\n\n[source,bash]\n----\ncd libs/morpheus-llm\nuv add <package>                                   # updates pyproject.toml + uv.lock\nuv pip compile pyproject.toml -o requirements.txt   # the manifest SAM builds from\n----\n\nNever `pip install` or `poetry`. Each layer is built by SAM with\n`BuildMethod: python3.12` against the library's `requirements.txt`, so an unexported\ndependency will not be in the deployed layer.\n\nLayer details, including size and retention: xref:infrastructure:layers.adoc[Lambda layers].\n\n== Tests\n\n[source,bash]\n----\ncd libs/morpheus-common     && uv run pytest tests -v    # event model validation\ncd libs/morpheus-data-layer && uv run pytest tests -v    # repositories (integration/ needs a DB)\ncd libs/pyb-hubspot         && uv run pytest tests -v    # HubSpot helpers, mocked SDK\ncd libs/morpheus-bigquery   && uv run pytest tests -v    # client factory\n----\n\n`morpheus-llm` has no test suite of its own; it is exercised through the functions that\nuse it.\n"},{"component":"morpheus","module":"libs","path":"libs:morpheus-common.adoc","title":"morpheus-common","description":"Event models, the SQS publisher, the cached secrets helper, and Sentry configuration.","url":"https://docx.payability.com/morpheus/libs/morpheus-common.html","navOrder":52,"source":"= morpheus-common\n:description: Event models, the SQS publisher, the cached secrets helper, and Sentry configuration.\n\nImport prefix `morpheus_common`. Layer `MorpheusCommonLayer` — always first in a\nfunction's layer list, because every handler calls `configure_sentry()` at cold start.\n\nSource: `libs/morpheus-common/morpheus_common/`.\n\n== Public surface\n\n[source,python]\n----\nfrom morpheus_common import configure, load_raw_secret\nfrom morpheus_common.events import (\n    SellerSyncEvent, ContactSyncPayload, ContactUpdateEvent, ContactUnsubscribeEvent,\n    ContactResolveHubSpotIdsEvent, CrmNewActivityEvent, CrmUpdateActivityEvent,\n    CrmTaskCreateEvent, CrmDialerCallEndedEvent, CrmHubSpotWebhookEvent,\n    HubSpotWebhookInnerEvent, FollowUpTaskSyncEvent, SubscriptionPreferenceUpdate,\n    CrmCustomEventEnvelope, CrmCustomEventAgentEmail, CustomEventKind,\n    custom_event_to_hubspot_properties, hubspot_event_name, parse_crm_custom_event,\n    publish_event,\n)\n----\n\n`CrmDealUpdateEvent` is defined in `events/models.py` but is *not* re-exported from\n`events/__init__.py`; import it from `morpheus_common.events.models` directly, as\n`morpheus_hubspot/registry.py` does.\n\n== `events/models.py` — the event contracts\n\nPydantic v2 models defining every message crossing a component boundary. They are the\nplatform's real interface, so a change here is a wire-format change.\n\nFull schemas: xref:events:crm-sync-queue.adoc[CrmSyncQueue events].\n\nConventions:\n\n* `event_type` defaults to the routing key, so constructing the model gives the producer\n  the key for free.\n* `ConfigDict(str_strip_whitespace=True)` on domain events;\n  `ConfigDict(extra=\"ignore\")` on anything mirroring an external payload.\n* Cross-field requirements live in a `model_validator(mode=\"after\")`:\n  `ContactUnsubscribeEvent` requires an id or an email, `CrmTaskCreateEvent` requires a\n  contact identifier, `CrmDealUpdateEvent` requires a deal or contact id,\n  `ContactUpdateEvent` requires non-empty `properties`.\n* `AliasChoices` where an external system uses different casing —\n  `HubSpotWebhookInnerEvent` accepts both `subscriptionType` and `subscription_type`.\n* Field descriptions are written for producers, including the traps: the\n  `CrmNewActivityEvent.hubspot_owner_id` description spells out that it must be the CRM\n  owner `id`, not a settings URL user id.\n\nThe module docstring documents both sides of the contract:\n\n[source,python]\n----\n# producer\nevent = SellerSyncEvent(seller_id=\"ATVPDKIKX0DER\", …, contacts=[ContactSyncPayload(…)])\npublish_event(queue_url=settings.crm_sync_queue_url, event=event, group_id=seller_id)\n\n# consumer\nevent = SellerSyncEvent.model_validate_json(record[\"body\"])\n----\n\n== `events/custom_events.py` — the behavioural-event whitelist\n\nOnly whitelisted `custom_event_kind` values are accepted, each with a dedicated model, so a\npayload is validated before any HubSpot call.\n\n[source,python]\n----\nclass CustomEventKind(StrEnum):\n    AGENT_EMAIL = \"agent_email\"\n\n_DEFAULT_HUBSPOT_EVENT_NAMES = {\"agent_email\": \"pe9298797_agentemail\"}\n\ndef hubspot_event_name(kind, *, override=None) -> str: ...\ndef parse_crm_custom_event(data: object) -> CrmCustomEventAgentEmail: ...\ndef custom_event_to_hubspot_properties(event) -> dict[str, str]: ...\n----\n\nThe HubSpot event name follows the portal-qualified `pe{hubId}_{internal_name}` pattern.\n`hubspot_event_name_override` on the event allows a per-message override.\n\n`custom_event_to_hubspot_properties()` maps the validated model to HubSpot's `properties`\ndict — `status` always, `campaign` and `link_url` only when set. The whitelist plus this\nexplicit mapping is the security boundary: an unknown kind fails validation, and an\nunmapped field never reaches HubSpot.\n\n== `events/publisher.py` — `publish_event()`\n\n[source,python]\n----\ndef publish_event(*, queue_url: str, event: BaseModel, group_id: str,\n                  dedup_id: str | None = None) -> None:\n----\n\nWraps `boto3` SQS `send_message` with FIFO defaults and Pydantic serialisation, keeping\nproducers free of boto3 boilerplate. The client is a module-level singleton pinned to\n`us-east-1`.\n\n`dedup_id` defaults to `sha256` of the serialised body. Logging is `DEBUG` level with the\nevent type, group id, and queue — never the body.\n\nGroup and deduplication key choices: xref:events:publishing.adoc[Publishing events].\n\n[NOTE]\n====\nFIFO only. `MessageGroupId` and `MessageDeduplicationId` are invalid on a standard queue,\nwhich is why `phone-agent` has its own `publish_to_standard_queue()` for\n`EmailAgentQueue`.\n====\n\n== `secrets.py` — `load_raw_secret()`\n\n[source,python]\n----\n@lru_cache(maxsize=4)\ndef load_raw_secret(secret_arn: str) -> dict:\n----\n\nOne Secrets Manager call per ARN per container lifetime, regardless of how many modules\nask. Returns the parsed JSON as a plain dict keyed by the exact Secrets Manager keys\n(`\"DATABASE_URL\"`, `\"HUBSPOT_CLIENT_ID\"`, …).\n\n`maxsize=4` covers the common single-ARN case with room to spare.\n\nRaises `RuntimeError` when the secret exists but has no string value;\n`json.JSONDecodeError` on invalid JSON; `botocore` `ClientError` on any AWS error.\n\nEach function wraps it in its own typed loader, mapping only the keys it needs:\n\n[source,python]\n----\n@lru_cache(maxsize=1)\ndef load_secrets() -> PhoneAgentSecrets:\n    data = load_raw_secret(settings.agentcore_secret_arn)\n    return PhoneAgentSecrets.model_validate({\n        \"database_url\":       data.get(\"DATABASE_URL\", \"\"),\n        \"twilio_account_sid\": data.get(\"TWILIO_ACCOUNT_SID\", \"\"),\n        …\n    })\n----\n\nTwo layers of caching, and `extra=\"ignore\"` on every secrets model, so an unrelated key can\nbe added to the shared secret without touching any function.\n\nThe cache is also why a rotated secret takes effect only on the next cold start. See\nxref:ROOT:configuration.adoc[Configuration and secrets].\n\n== `sentry.py` — `configure()`\n\n[source,python]\n----\ndef configure() -> None:\n----\n\nReads everything from the environment and does nothing when `SENTRY_DSN` is empty or\nunset — which is the current production state.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Variable | Effect\n\n| `SENTRY_DSN` | Empty or unset disables Sentry entirely\n| `SENTRY_ENVIRONMENT` | Environment tag; defaults to `production` (the SAM template sets `prod`)\n| `SENTRY_RELEASE` | Optional release identifier for correlating errors to deploys\n| `SENTRY_TRACES_SAMPLE_RATE` | Documented, but the `traces_sample_rate` line is currently commented out\n|===\n\nIntegrations when enabled:\n\n* `AwsLambdaIntegration(timeout_warning=True)` — captures unhandled handler exceptions and\n  flushes synchronously before the runtime freezes.\n* `LoggingIntegration(level=INFO, event_level=ERROR)` — `INFO`+ records become breadcrumbs,\n  `ERROR`+ records become events.\n* `send_default_pii=False` — strips IP addresses and user agents.\n\nTwo safety properties: it is idempotent (safe to call more than once), and initialisation\nfailure is caught and logged at `WARNING` rather than breaking the cold start. An\nobservability failure must never take down a function.\n\n[IMPORTANT]\n====\n`LoggingIntegration` forwards `ERROR`-level records as events, so any PII placed in an error\nmessage reaches Sentry despite `send_default_pii=False`. The `morpheus-hubspot` dispatcher\nlogs full message bodies on failure — worth resolving before enabling a DSN. See\nxref:ROOT:security.adoc#pii[PII].\n====\n\n== Tests\n\n[source,bash]\n----\ncd libs/morpheus-common && uv run pytest tests -v\n----\n\n`test_contact_update_event.py`, `test_contact_unsubscribe_event.py`,\n`test_contact_resolve_hubspot_ids_event.py`, `test_crm_hubspot_webhook_event.py`,\n`test_custom_events.py` — all validation-focused, asserting that the model rejects what it\nshould. Since these models *are* the wire contract, that is the right place to spend test\neffort.\n"},{"component":"morpheus","module":"libs","path":"libs:morpheus-llm.adoc","title":"morpheus-llm","description":"Strands agent services, Bedrock model configuration, MCP tools, and structured output.","url":"https://docx.payability.com/morpheus/libs/morpheus-llm.html","navOrder":53,"source":"= morpheus-llm\n:description: Strands agent services, Bedrock model configuration, MCP tools, and structured output.\n\nImport prefix `morpheus_llm`. Layer `MorpheusLlmLayer`. Also an editable path dependency of\nthe AgentCore runtime.\n\nSource: `libs/morpheus-llm/morpheus_llm/`.\n\nEvery Bedrock call and every Strands `Agent` construction in the platform goes through this\nlibrary, so model choice, prompt caching, memory wiring, and MCP transport behaviour are\nchangeable in one place.\n\n== Lazy imports\n\n`morpheus_llm/__init__.py` uses PEP 562 `__getattr__`, so `import morpheus_llm.utils` does\nnot pull in Bedrock, Strands, or `bedrock_agentcore`:\n\n[source,python]\n----\ndef __getattr__(name: str):\n    if name == \"ClaudeAgentService\":\n        from morpheus_llm.providers.claude_service import ClaudeAgentService\n        return ClaudeAgentService\n    …\n----\n\nConsumers add a second layer of laziness, deferring imports inside `@lru_cache` functions so\na module stays importable without the layer installed. See\nxref:lambdas:testing.adoc[Testing Lambdas].\n\n== `providers/base_service.py` — `BaseAgentService`\n\nThe provider-agnostic base: tool resolution, AgentCore Memory session management, and the\npublic `run` / `new_session` API. A concrete provider implements one method.\n\n[source,python]\n----\nclass BaseAgentService(ABC):\n    def __init__(self, config: AgentConfig,\n                 tools: list[Any] | Callable[[], list[Any]],\n                 memory_id: str | None = None) -> None:\n        self._memory_id = memory_id or os.environ.get(\"MEMORY_ID\", \"\")\n\n    def _resolve_tools(self) -> list[Any]:\n        return self._tools() if callable(self._tools) else self._tools\n\n    def _make_session_manager(self, options) -> AgentCoreMemorySessionManager | None: ...\n\n    @abstractmethod\n    def _make_agent(self, options: RunOptions | None = None) -> Any: ...\n\n    def run(self, prompt: str, options=None) -> AgentResult: ...\n    def new_session(self, options=None) -> Any: ...\n----\n\n`run()` builds a fresh agent, calls it once, and wraps the result:\n\n[source,python]\n----\nreturn AgentResult(\n    output=str(result),\n    stop_reason=\"end_turn\",\n    structured_output=getattr(result, \"structured_output\", None),\n)\n----\n\n`stop_reason` is hardcoded to `\"end_turn\"` — the underlying `max_turns` state is not\ncurrently surfaced.\n\n`new_session()` returns a fresh callable agent for a multi-step workflow. Both return a\n*fresh* agent every time, because service instances are container-lifetime singletons and a\nshared agent would leak conversation history between requests.\n\nMemory wiring: xref:agentcore:memory.adoc[AgentCore Memory].\n\n== `providers/claude_service.py` — `ClaudeAgentService`\n\nClaude on Bedrock through Strands. Only responsibility: build the `Agent` with a\n`BedrockModel`.\n\n[source,python]\n----\ndef _make_agent(self, options: RunOptions | None = None) -> Agent:\n    cache_prompt, cache_tools = _bedrock_prompt_tool_cache_flags(self._config.model_id)\n    model = BedrockModel(\n        model_id=self._config.model_id,\n        max_tokens=self._config.max_tokens,\n        cache_prompt=cache_prompt,\n        cache_tools=cache_tools,\n    )\n    system_prompt = (options.system_prompt\n                     if options and options.system_prompt\n                     else self._config.system_prompt)\n    return Agent(\n        model=model,\n        system_prompt=system_prompt,\n        tools=self._resolve_tools(),\n        session_manager=self._make_session_manager(options) if options else None,\n        structured_output_model=self._config.structured_output_model,\n    )\n----\n\nAuthentication is the ambient IAM identity — the Lambda execution role or the runtime role.\nNo API key.\n\n[#caching]\n=== Prompt and tool caching\n\n[source,python]\n----\ndef _bedrock_prompt_tool_cache_flags(model_id: str) -> tuple[str | None, str | None]:\n    m = model_id.lower()\n    if \"anthropic\" in m or \"claude\" in m:\n        return \"default\", \"default\"\n    return None, None\n----\n\nBedrock `cachePoint` markers are Anthropic-only. Passing them to Amazon Nova produces\n`ValidationException: extraneous key [cachePoint] is not permitted` in\n`toolConfig/tools`. The flags are therefore derived from the model id, which is why the\nNova-based email categorisation agent works without any special handling at the call site.\n\nCaching matters most for the enrichment agent, where a long system prompt and two MCP tool\nschemas are re-sent on every turn.\n\n[#structured-output]\n== Structured output\n\nSet `AgentConfig.structured_output_model` to a Pydantic class and Strands adds a dedicated\noutput *tool*, so the model returns validated structured data rather than text to parse.\n\n[source,python]\n----\nconfig = AgentConfig(\n    model_id=AgentModelIds.CLAUDE_HAIKU_4_5,\n    system_prompt=\"…\",\n    max_tokens=2048,\n    max_turns=6,\n    structured_output_model=SellerEnrichmentResult,\n)\nresult = agent.run(prompt)\ndata: SellerEnrichmentResult = result.structured_output   # a validated instance\n----\n\nNever `json.loads(result.output)`. If `structured_output` is `None`, the model failed to\ncall the output tool — treat that as an error, as `run_reply_categorisation()` does by\nraising `RuntimeError`.\n\nUsed by all three Lambda agents. The AgentCore runtime's `BaseWorkflow` uses a weaker\nprompt-level mechanism instead — see xref:agentcore:workflows.adoc[Workflows].\n\n== `types/__init__.py`\n\n[source,python]\n----\n@dataclass\nclass AgentConfig:\n    model_id: str\n    system_prompt: str\n    max_tokens: int\n    max_turns: int\n    structured_output_model: type | None = None\n\n@dataclass\nclass AgentResult:\n    output: str\n    stop_reason: str = \"end_turn\"\n    usage: dict[str, int] | None = None\n    structured_output: Any = None\n\n@dataclass\nclass RunOptions:\n    system_prompt: str | None = None\n    max_turns: int | None = None\n    metadata: dict[str, Any] | None = None\n    session_id: str | None = None       # required for memory\n    actor_id: str | None = None         # memory namespace key\n----\n\n[NOTE]\n====\n`AgentConfig.max_turns` and `RunOptions.max_turns` are carried through the API but are not\npassed to the Strands `Agent` in `_make_agent()`. Treat them as documentation of intent\nrather than an enforced ceiling.\n====\n\n`usage` keys observed in logs: `inputTokens`, `outputTokens`, `cacheWriteInputTokens`,\n`cacheReadInputTokens`.\n\n[#models]\n== `utils/enums.py` — `AgentModelIds`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Member | Model id\n\n| `CLAUDE_OPUS_4_6` | `us.anthropic.claude-opus-4-6-v1`\n| `CLAUDE_SONNET_4` | `us.anthropic.claude-sonnet-4-20250514-v1:0`\n| `CLAUDE_HAIKU_4_5` | `us.anthropic.claude-haiku-4-5-20251001-v1:0`\n| `CLAUDE_HAIKU_3_5` | `us.anthropic.claude-3-5-haiku-20241022-v1:0`\n| `CLAUDE_HAIKU_3` | `us.anthropic.claude-3-haiku-20240307-v1:0`\n| `NOVA_MICRO` | `us.amazon.nova-micro-v1:0`\n| `NOVA_LITE` | `us.amazon.nova-lite-v1:0`\n| `NOVA_2_LITE` | `us.amazon.nova-2-lite-v1:0`\n|===\n\n[IMPORTANT]\n====\nDo not strip the `us.` prefix. Newer models are only available through cross-region\ninference profiles, which the prefix selects. Older models (3.5 and below) also support\ndirect on-demand invocation with a bare `anthropic.*` id, but the enum uses the prefixed\nform throughout for consistency.\n====\n\nIn use today: `CLAUDE_SONNET_4` (runtime dialler agent), `CLAUDE_HAIKU_4_5` (seller\nenrichment), `CLAUDE_HAIKU_3` (payout-pain scoring), `NOVA_LITE` (email reply\ncategorisation).\n\n== `tools/`\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Export | Purpose\n\n| `add_numbers` | Demonstration `@tool`\n| `create_hubspot_mcp(*, client_id, client_secret, refresh_token)` | HubSpot Remote MCP client with 429 retry\n| `create_tavily_mcp(*, api_key)` | Tavily Remote MCP client — web search and extraction\n| `create_agentcore_browser(region=None, session_timeout=3600, identifier=None)` | Managed cloud Chrome; requires the `[browser]` extras\n|===\n\nEndpoints (`constants.py`): `https://mcp.hubspot.com/` and `https://mcp.tavily.com/mcp/`.\n\n=== HubSpot MCP transport\n\nTwo behaviours worth knowing:\n\n* `_RetryOn429Transport` wraps the `httpx` async transport and retries HTTP 429 up to 4\n  times with exponential backoff (1 s base, capped at 30 s per sleep), honouring\n  `Retry-After` when present.\n* `terminate_on_close=False`, because HubSpot's MCP server does not implement the DELETE\n  session-termination endpoint and returns 405 if it is called.\n\nThe access token is obtained by `refresh_hubspot_access_token()` and cached with\n`@lru_cache(maxsize=4)` keyed on the credential triple, so the OAuth round-trip happens once\nper container. Use `_get_access_token.cache_clear()` in tests.\n\n=== Factories, not objects\n\nEvery MCP tool is a factory. `MCPClient` holds an open HTTP session and cannot be reused\nacross agent invocations, so tool sets containing one must be supplied as a `lambda`:\n\n[source,python]\n----\ntools=lambda: [create_tavily_mcp(api_key=secrets.tavily_api_key),\n               create_hubspot_mcp(client_id=…, client_secret=…, refresh_token=…)]\n----\n\nDetails: xref:agentcore:tools.adoc[Tools].\n\n== `utils/hubspot.py`\n\nRe-exports `pyb_hubspot.auth.refresh_hubspot_access_token`. Token refresh is a\nHubSpot-specific credential concern needed by both this layer and the CRM sync Lambda, so\n`pyb-hubspot` owns the implementation and this module keeps existing importers working.\n\n== Consumers\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Consumer | Uses\n\n| enrichment-scorer | `ClaudeAgentService`, both MCP factories, `AgentConfig`, `AgentModelIds`\n| email-agent | `ClaudeAgentService`, `AgentConfig`, `AgentModelIds` (Nova)\n| agentcore runtime | `ClaudeAgentService`, `BaseAgentService`, `create_hubspot_mcp`, `add_numbers`, all types\n|===\n\n== Extras\n\n`morpheus-llm[browser]` adds `strands-agents-tools`, `playwright`, and `nest-asyncio` for\n`create_agentcore_browser()`. The AgentCore runtime depends on the extras variant; the\nLambda layer does not.\n\n[NOTE]\n====\nThe split is a packaging constraint, not a preference. Playwright ships browser binaries that\nblow past the 250 MB unzipped limit a zip-based layer shares with the function and every other\nlayer — see xref:infrastructure:layers.adoc[Lambda layers]. The AgentCore runtime is a\ncontainer image, so it has room.\n\nA Lambda that genuinely needs browser automation would have to be repackaged as a container\nimage rather than gaining a sixth layer. Nothing needs that today.\n====\n"},{"component":"morpheus","module":"libs","path":"libs:morpheus-data-layer.adoc","title":"morpheus-data-layer","description":"Session management, connection pooling, ORM models, and the repository layer over PostgreSQL.","url":"https://docx.payability.com/morpheus/libs/morpheus-data-layer.html","navOrder":54,"source":"= morpheus-data-layer\n:description: Session management, connection pooling, ORM models, and the repository layer over PostgreSQL.\n\nImport prefix `morpheus_data_layer`. Layer `MorpheusDataLayer`. Used by all five Lambda\nfunctions; *not* used by the AgentCore runtime, which has no database access.\n\nSource: `libs/morpheus-data-layer/morpheus_data_layer/`.\n\nThe schema is owned by the Payability web application's Prisma schema; this library is a\nread/write ORM over a subset of it. See xref:data:prisma-schema.adoc[Prisma schema\nreference].\n\n== `session.py`\n\nA module-level engine singleton, so one connection pool serves the whole process — whether\nthat is a warm Lambda container, the AgentCore runtime, or a script.\n\n[source,python]\n----\ndef init(database_url: str, *, pool_size: int = 5, max_overflow: int = 2) -> None\n@contextmanager\ndef get_session() -> Generator[Session, None, None]\ndef check_connection() -> bool\n----\n\n`init()` is idempotent: a second call is a no-op and the existing engine and pool are kept\nintact.\n\nThe layer installs `psycopg2-binary` only. `init()` therefore always names the driver:\n`postgresql://`, `postgres://`, and `postgresql+psycopg://` are rewritten to\n`postgresql+psycopg2://` (the last with a warning). Without this, SQLAlchemy 2.1+ resolves a\nbare `postgresql://` to psycopg 3 and the cold start fails with\n`No module named 'psycopg'`. SQLAlchemy is also pinned to `>=2.0,<2.1` in\n`requirements.txt`, the file SAM installs the layer from, so the layer matches the version\nin `uv.lock` that the tests run against.\n\n[#pooling]\n=== Pooling\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| `pool_size` | Behaviour\n\n| `0` | `NullPool` — a fresh connection per `get_session()` block, released on exit\n| `> 0` | `QueuePool` with `pool_pre_ping=True`, `pool_recycle=300`, and `max_overflow`\n|===\n\n[IMPORTANT]\n====\n*Always `pool_size=0` in a Lambda.* Every function calls\n`init(_secrets.database_url, pool_size=0)` at cold start.\n\nEach Lambda container handles one invocation at a time and gains nothing from a persistent\npool, while `NullPool` lets Supabase's PgBouncer multiplex efficiently across hundreds of\nconcurrent containers. A persistent pool per container would exhaust the connection limit\ninstead.\n\nEqually: use the Supabase *Transaction mode* pooler URL (port `6543`), not Session mode\n(port `5432`). Session mode reserves one server connection per client for its entire\nlifetime.\n====\n\nEvery new connection runs `SET search_path TO public` through a `connect` event listener.\n\n=== Transaction boundary\n\n[source,python]\n----\nwith get_session() as session:\n    seller = _seller_repo.get_by_seller_id(session, seller_id)\n    _seller_repo.update(session, seller, {\"lifeCycleStage\": SellerLifeCycleStage.ENRICHED})\n# committed here\n----\n\nCommits on clean exit; rolls back and re-raises on any exception; always closes. The session\nfactory uses `expire_on_commit=False`, so scalar attributes stay readable on a detached\nobject after the block ends.\n\n[NOTE]\n====\nTwo consequences of `expire_on_commit=False` worth internalising:\n\n* *Scalars* survive the block. Relationships and unloaded columns do not — accessing them\n  after commit raises. This is why `_build_crm_sync_event()` in `enrichment-scorer` must run\n  *inside* the session, and why `_upsert_seller()` explicitly touches `enrichedAt`,\n  `payoutPainScore`, and `note` before the block ends.\n* Handlers that call an external API between reads and writes deliberately use *two* short\n  sessions rather than one long one, so a connection is not held open across network\n  latency. `email-agent`'s categorisation handler is the clearest example.\n====\n\n`check_connection()` runs `SELECT 1` and returns a boolean — for health checks.\n\n== `models/`\n\nOne SQLAlchemy 2.0 declarative model per file, all on a shared `Base`, exported from\n`models/__init__.py`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Model | Table\n\n| `Seller` | `sellers`\n| `Contact` | `contacts`\n| `Activity` | `Activity`\n| `Call` | `Call`\n| `CampaignTarget` | `CampaignTarget`\n| `Configs` | `configs`\n| `Email` | `emails`\n| `EmailCampaignTarget` | `email_campaign_targets`\n| `EmailVariation` | `email_variations`\n| `FollowUpTask` | `follow_up_tasks`\n| `SalesRep` | `sales_reps`\n| `TargetEmails` | `target_emails`\n| `TargetPhoneNumber` | `target_phone_numbers`\n|===\n\nConventions:\n\n* Attribute names are *camelCase* and match the database column exactly. No `name=`\n  override in `mapped_column` — the Python and SQL names are identical, which is unusual for\n  SQLAlchemy but keeps `conflict_columns` lists and raw SQL honest.\n* `ForeignKey(\"subscriptions.id\")` is *not* declared. That table is not managed here; the\n  constraint is enforced at the database level only.\n* One model per file, always exported from `models/__init__.py`.\n\nDetails: xref:data:orm-models.adoc[ORM models].\n\n== `repositories/`\n\nStateless CRUD objects, instantiated once at module level by consumers:\n\n[source,python]\n----\n_seller_repo = SellerRepository()      # module level — stateless, safe to share\n----\n\nThey *never* open sessions and *never* commit. Every method takes a `Session` as its first\nargument.\n\n=== `BaseRepository[ModelT]`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_id(session, record_id)` | `session.get()` — `None` if missing\n| `get_all(session, *, limit=100, offset=0)` | Paged select\n| `filter_by(session, **kwargs)` | Equality filters, all matches\n| `first_by(session, **kwargs)` | Equality filters, first match\n| `insert(session, data)` | Add, flush, refresh, return the instance\n| `bulk_insert(session, rows)` | Core `insert()` for many rows\n| `upsert(session, data, conflict_columns, update_columns=None)` | PostgreSQL `ON CONFLICT DO UPDATE` with `RETURNING`; `update_columns` defaults to every non-conflict key in `data`\n| `update(session, record, data)` | `setattr` each field, flush, refresh\n| `update_by_id(session, record_id, data)` | Fetch then update; `None` if missing\n|===\n\n`conflict_columns` and `update_columns` take *camelCase database column names*, matching the\nORM attributes.\n\n=== Concrete repositories\n\nEach adds query methods for the access patterns the handlers actually need. Notable ones:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Repository | Domain-specific methods\n\n| `SellerRepository` | `get_by_seller_id`, `get_pending_enrichment`, `get_pending_scoring`, `get_by_platform`, `get_suspended`, `upsert_by_seller_id`\n| `ContactRepository` | `get_by_email`, `get_by_phone`, `get_by_hubspot_contact_id`, `get_by_seller`, `list_missing_hubspot_contact_id`, `list_missing_hubspot_id_by_last_sync`, `find_existing`, `upsert_for_seller`\n| `FollowUpTaskRepository` | `list_pending_due_soon`, `list_pending_with_hubspot_task`, `has_active_task_for_contact`, `list_stale_for_escalation`\n| `SalesRepRepository` | `get_active_by_email`, `get_active_by_hubspot_owner_id`, `get_by_hubspot_owner_id`, `list_active_for_subscription`\n| `CallRepository` | `get_by_provider_call_id`, `get_with_target_for_subscription`, `get_with_target_by_ids`\n| `ConfigsRepository` | `get_by_key`, `get_value`, `get_by_subscription`, `set_value`\n| `TargetPhoneNumberRepository` | `get_by_phone_number`, `list_pending_twilio_sync`\n| `TargetEmailsRepository` | `get_by_subscription_and_email`, `set_newsletter_opt_out`\n| `EmailCampaignTargetRepository` | `get_by_campaign_and_email`, `update_categorisation`\n| `ActivityRepository` | `get_by_external_id`, `list_by_contact`\n|===\n\n`EmailRepository` and `EmailVariationRepository` add nothing beyond the base.\n\nFull signatures: xref:data:repositories.adoc[Repositories].\n\n=== Round-robin selection\n\n`repositories/sales_rep_round_robin.py` provides `select_next_sales_rep_round_robin()`,\n`reserve_sales_rep_round_robin_batch()` and `dialer_rr_config_key()`. These are *functions*,\nnot repository methods, because they span `sales_reps` and `configs` and take a PostgreSQL\nadvisory transaction lock. See xref:business:ownership.adoc[Ownership and round-robin].\n\n`reserve_sales_rep_round_robin_batch(session, subscription_id, *, require_nooks, count)`\nreserves `count` reps in one call, advancing the cursor once. Prefer it when assigning many\nrecords at once: `select_next_sales_rep_round_robin()` (which now delegates to it with\n`count=1`) costs a rep query, an advisory lock and a cursor write *per call*, so calling it in\na loop over thousands of rows will exhaust a Lambda timeout.\n\n== `enums.py`\n\n`StrEnum` mirrors of the Prisma/PostgreSQL enums, so a comparison against a string literal\nworks but the canonical member is available: `SellerPlatform`, `SellerDatasource`,\n`SellerLifeCycleStage`, `ContactHubspotSyncStatus`, `ContactSource`, `ContactHeatStatus`,\n`ContactLifecycleStage`, `ContactLeadStatus`, `EmailTargetStatus`, `EmailTargetCategory`,\n`EmailDirection`, `EmailInboxCategory`, `EmailVariationStatus`, `LineTypes`, `RiskLevel`,\n`CallStatus`, `TargetState`, `FollowUpTaskType`, `FollowUpTaskStatus`, `ActivityType`.\n\nNote `LineTypes` uses camelCase *values* (`fixedVoip`, `nonFixedVoip`, `tollFree`) because\nthey mirror Twilio's vocabulary exactly. Full listing:\nxref:data:enums.adoc[Enumerations].\n\n== Adding a model or repository\n\n. Create `models/<name>.py` with camelCase attributes matching the columns exactly. No\n  `subscriptions` foreign key.\n. Export it from `models/__init__.py`.\n. Create `repositories/<name>.py` extending `BaseRepository[YourModel]` with\n  `model_class = YourModel`.\n. Add only the query methods the callers need. No sessions, no commits.\n. Export it from `repositories/__init__.py`.\n. Add a test under `tests/`.\n\n== Tests\n\n[source,bash]\n----\ncd libs/morpheus-data-layer && uv run pytest tests -v\n----\n\n`tests/integration/` (`test_connection.py`, `test_seller_repository.py`,\n`test_configs_repository.py`) needs a reachable database and reads its connection string\nfrom `libs/morpheus-data-layer/.env`. A `Makefile` in the library directory wraps the common\ncommands.\n"},{"component":"morpheus","module":"libs","path":"libs:morpheus-bigquery.adoc","title":"morpheus-bigquery","description":"The BigQuery client factory used by every warehouse-backed handler.","url":"https://docx.payability.com/morpheus/libs/morpheus-bigquery.html","navOrder":55,"source":"= morpheus-bigquery\n:description: The BigQuery client factory used by every warehouse-backed handler.\n\nImport prefix `morpheus_bigquery`. Layer `MorpheusBigQueryLayer`. Used by\n`enrichment-scraper`, `morpheus-hubspot`, and `phone-agent`.\n\nSource: `libs/morpheus-bigquery/morpheus_bigquery/client.py`. The smallest library in the\nrepository — one function.\n\n== `create_bigquery_client()`\n\n[source,python]\n----\ndef create_bigquery_client(*, credentials_json: str) -> bigquery.Client:\n    \"\"\"Build a BigQuery client from a service account JSON string.\"\"\"\n    info = json.loads(credentials_json)\n    credentials = service_account.Credentials.from_service_account_info(info)\n    return bigquery.Client(credentials=credentials, project=credentials.project_id)\n----\n\nCredentials are always passed by the consumer — the library never reads an environment\nvariable. The project is taken from the service account itself rather than configured\nseparately, so a credential swap moves the client to the right project automatically.\n\n== Usage\n\nThe import is *deferred into the handler body*, not done at module scope, so the module stays\nimportable in a test environment without the layer:\n\n[source,python]\n----\ndef handle(self, event: ScheduledEvent, context: Any) -> None:\n    secrets = load_secrets()\n\n    if not secrets.bigquery_credentials.strip():\n        logger.warning(\"BigQuery credentials not configured — skipping …\")\n        return\n\n    from morpheus_bigquery import create_bigquery_client          # deferred\n\n    bq_client = create_bigquery_client(credentials_json=secrets.bigquery_credentials)\n    rows = [dict(row) for row in bq_client.query(SOME_QUERY).result()]\n----\n\nEvery warehouse-backed handler follows this shape: check for credentials, warn and return if\nabsent, then build the client.\n\n== Credentials\n\n`BIGQUERY_CREDENTIALS` in the shared secret — a Google service-account JSON document stored\nas a *string*. Each consuming function declares it as an optional field defaulting to `\"\"`:\n\n[source,python]\n----\nclass HubspotSecrets(BaseModel):\n    bigquery_credentials: str = \"\"\n----\n\nAn empty value is a supported state, not an error. It disables every BigQuery-backed handler\nwith a warning:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Function | Handlers disabled by empty credentials\n\n| morpheus-hubspot | `Hubspot.EmailClickReEngagement`, `Hubspot.SupplierAccountReEngagement`, `Hubspot.UnattendedMeetingReEngagement`, `Hubspot.LostDealReEngagement`, `Hubspot.BigQueryEngagementSync`\n| enrichment-scraper | `Scraper.BigQueryContactSync`\n| phone-agent | `Phone.CampaignTargetReEngagement`\n|===\n\nClearing the key is therefore a single switch that turns off all warehouse-driven\nsegmentation. See xref:ROOT:operations.adoc#kill-switches[kill switches].\n\n== Query patterns\n\nTwo styles appear in the handlers.\n\nLiteral query, results as dicts::\n[source,python]\n----\nresult = client.query(BQ_LOST_DEAL_REENGAGEMENT_QUERY).result()\nrows = [dict(row) for row in result]\n----\n+\nUsed by all four re-engagement segments. The query is a constant in the function's\n`utils/constants.py`, selecting from a named view with a `LIMIT`.\n\nParameterised query::\n[source,python]\n----\nfrom google.cloud import bigquery as bq\n\njob_config = bq.QueryJobConfig(query_parameters=[\n    bq.ScalarQueryParameter(\"watermark\", \"TIMESTAMP\", watermark_to_dt(watermark)),\n    bq.ScalarQueryParameter(\"row_limit\", \"INT64\", limit),\n])\nresult = client.query(BQ_CONTACT_SYNC_QUERY, job_config=job_config).result()\n----\n+\nUsed by the two watermark-driven syncs. Parameters, not interpolation — the watermark comes\nfrom the `configs` table.\n\n== Type coercion\n\nBigQuery `TIMESTAMP` values arrive as `datetime` objects, but exports and casts can produce\nstrings or epoch integers. Every handler carries a defensive coercion helper:\n\n[source,python]\n----\ndef _to_datetime(value: Any) -> datetime | None:\n    if value is None:\n        return None\n    if isinstance(value, datetime):\n        return value if value.tzinfo else value.replace(tzinfo=timezone.utc)\n    if isinstance(value, (int, float)):\n        return datetime.fromtimestamp(value / 1000, tz=timezone.utc)   # epoch ms\n    try:\n        return datetime.fromisoformat(str(value))\n    except (ValueError, TypeError):\n        return None\n----\n\nNaive datetimes are assumed UTC. An unparseable value becomes `None`, which the calling\nsuppression rules interpret as \"no known last call\" — deliberately the *permissive*\ndirection, so a parse failure never silently suppresses a contact.\n\nThe helper is duplicated across handlers rather than shared. It is a candidate for this\nlibrary if a fourth copy appears.\n\n== Warehouse layout\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Dataset | Contents\n\n| `{bq-project}.{bq-dataset-hubspot}` | Stitch export of the HubSpot portal — `contacts`, `deals`, `engagements`, `v_engagements_with_owner`\n| `{bq-project}.{bq-dataset-app}` | Mirror of application tables plus the derived views Morpheus queries\n|===\n\nDetails: xref:data:warehouse.adoc[BigQuery warehouse] and\nxref:data:bigquery-views.adoc[BigQuery views].\n\n== Tests\n\n[source,bash]\n----\ncd libs/morpheus-bigquery && uv run pytest tests -v\n----\n\n`tests/test_client.py` covers the factory. The handler-level query and coercion logic is\ntested in the consuming functions' suites.\n"},{"component":"morpheus","module":"libs","path":"libs:pyb-hubspot.adoc","title":"pyb-hubspot","description":"The HubSpot client and every HubSpot API call in the platform.","url":"https://docx.payability.com/morpheus/libs/pyb-hubspot.html","navOrder":56,"source":"= pyb-hubspot\n:description: The HubSpot client and every HubSpot API call in the platform.\n\nImport prefix `pyb_hubspot`. Layer `PybHubspotLayer`. Used by `morpheus-hubspot` and\n`enrichment-scorer`.\n\nSource: `libs/pyb-hubspot/pyb_hubspot/`.\n\nEvery HubSpot API call lives here. Handlers never `import hubspot.*` directly, which keeps\nauth modes, property mapping, association type ids, and disposition GUIDs in one place.\n\n== Clients\n\nTwo, sharing the same bearer token.\n\n`HubSpotSyncClient`::\nWraps the official HubSpot Python SDK. Exposes `companies`, `contacts`, `deals`,\n`associations`, and `lists` from `crm.*`, plus `access_token` for callers that need the raw\ntoken.\n\n`HubSpotHttpClient`::\nDirect `api.hubapi.com` requests for endpoints the SDK does not cover — currently\nCommunication Preferences. Obtain one from a sync client with `as_http_client()`, or build\nit directly.\n\n=== Authentication\n\n[source,python]\n----\n# Private App token — preferred: static, no refresh\nclient = HubSpotSyncClient.from_private_app_token(\"pat-na1-…\")\n\n# OAuth credentials — fallback\nclient = HubSpotSyncClient.from_oauth(\n    client_id=secrets.hubspot_client_id,\n    client_secret=secrets.hubspot_client_secret,\n    refresh_token=secrets.hubspot_refresh_token,\n)\n\nhttp = client.as_http_client()\n----\n\nBoth factories return the same object, so library code taking a client does not need to know\nwhich mode produced it. Use the factory class methods rather than `__init__`.\n\n`auth.refresh_hubspot_access_token()` performs the OAuth exchange. It lives here — not in\n`morpheus-llm` — because token refresh is a HubSpot credential concern needed by both the LLM\nlayer's MCP tool and the CRM sync Lambda; `morpheus_llm.utils.hubspot` re-exports it.\n\n== Module map\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Module | Contents\n\n| `client.py` | `HubSpotSyncClient` and its two factories\n| `clients/http_client.py` | `HubSpotHttpClient` — REST calls outside the SDK\n| `auth.py` | `refresh_hubspot_access_token()`\n| `sync/company.py` | `upsert_company()` — three-step company match\n| `sync/contact.py` | `upsert_contact()` — two-step contact match + company association\n| `sync/result.py` | `UpsertResult(id, created)`\n| `models.py` | `seller_to_company_props()` and friends — domain → HubSpot property mapping\n| `contact_properties.py` | `ContactPatchProperties`, `update_contact_properties()`\n| `contact_search.py` | `get_crm_contact()`, `search_contact_ids_by_property_eq()`, `resolve_hubspot_contact_id_phone_first()`, `PhoneFirstResolveResult`\n| `activities.py` | `create_email_activity()`, `update_crm_email()`, `send_custom_event()`\n| `email_properties.py` | `EmailActivityCreateProperties`, `EmailActivityUpdateProperties`\n| `call_activities.py` | `CallEngagementInput`, `create_call_engagement()`\n| `disposition_outcomes.py` | `Disposition`, `NextAction`, `get_hubspot_outcome_id()`\n| `engagements.py` | `log_note()`\n| `tasks.py` | `create_crm_task()`, `get_crm_task()`, `update_crm_task()`, `create_incoming_email_followup_task()`\n| `deals.py` | `get_open_deal_ids_for_contact()`, `update_deal_stage()`, `add_note_to_deal()`\n| `lists.py` | `create_contact_list()`, `delete_list()`, `build_call_list_filter_branch()`\n| `communications/preferences.py` | `apply_subscription_preference_updates()`\n| `utils/enums/endpoints.py` | `HubSpotApiEndpoint`\n| `utils/constants.py`, `utils/payloads.py` | Shared constants and payload builders\n|===\n\n== Sync — company and contact upsert\n\nBoth return enough information for the caller to decide whether to log an update Note.\n\n`upsert_company(client, event) -> UpsertResult`::\nSearch priority, stopping at the first hit:\n+\n. `hubspot_company_id` from the event — skips searching entirely.\n. The custom `amazon_seller_id` property — catches companies created by an earlier sync whose\n  id was never written back.\n. `name` — catches companies created outside Morpheus.\n+\nNo match creates a new Company.\n\n`upsert_contact(client, payload, company_id) -> str | None`::\nSearch priority:\n+\n. `hubspot_contact_id` from the payload.\n. `email` — HubSpot's own primary deduplication key for Contacts.\n+\nNo match creates a new Contact, then associates it with the Company through the v4\nAssociations API. A payload with no email and no existing id returns `None` — there is\nnothing to deduplicate on.\n\nProperty mapping lives in `models.py` so the sync functions stay free of field-name strings\nand the mapping can be unit-tested on its own.\n\n== Contact search\n\n`resolve_hubspot_contact_id_phone_first(client, *, email, phone, mobile_phone)` returns a\n`PhoneFirstResolveResult` carrying the resolved id, the `match_step` that found it, and\n`had_multiple_matches`. Callers log `match_step`, which makes a resolution auditable after the\nfact, and log a warning on multiple matches while using the first.\n\n`search_contact_ids_by_property_eq(client, prop, value, limit)` is the primitive underneath —\nan equality filter on any standard property. The scheduled `Hubspot.ContactResolve` handler\nuses it directly to try `phone` and `mobilephone` against every related\n`target_phone_numbers` row.\n\n== Typed properties\n\nArbitrary HubSpot property names are allowed via `extra=\"allow\"`, while the documented custom\nfields are typed:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Model | Use\n\n| `ContactPatchProperties` | Contact PATCH — any internal property name plus documented custom fields\n| `EmailActivityCreateProperties` | Email object POST; `hs_timestamp` defaults to now UTC when omitted, because HubSpot requires it on create\n| `EmailActivityUpdateProperties` | Email object PATCH; partial fields, no auto-filled timestamp\n|===\n\nValidating before the call means a malformed property dict fails in the handler with a\nPydantic error rather than as an opaque HubSpot 400.\n\n== Call engagements and dispositions\n\n`create_call_engagement(client, hubspot_contact_id, CallEngagementInput(...), extra_properties=…)`\ncreates a Call object and associates it with a Contact (v3 create plus v4 associations).\n`CallEngagementInput` carries duration, summary HTML, transcript, recording URL, and start\ntimestamp.\n\n`disposition_outcomes.py` holds the disposition vocabulary and the GUID tables:\n\n[source,python]\n----\nclass Disposition(StrEnum):\n    QUALIFIED_LEAD = \"Qualified_Lead\"\n    AMAZON_SELLER = \"Amazon_Seller\"\n    SALES_HANDOFF = \"Sales_Handoff\"\n    CALLBACK_REQUESTED = \"Callback_Requested\"\n    NOT_QUALIFIED = \"Not_Qualified\"\n    NOT_INTERESTED = \"Not_Interested\"\n    WRONG_NUMBER = \"Wrong_Number\"\n    DO_NOT_CALL = \"Do_Not_Call\"\n    NOT_A_SELLER = \"Not_a_Seller\"\n    HANGUP_EARLY = \"Hangup_Early\"\n    AI_ISSUE = \"AI_Issue\"\n    BUSY = \"Busy\"\n    AI_SCREENING = \"AI_Screening\"\n    GATEKEEPER = \"Gatekeeper\"\n    VOICEMAIL_DETECTED = \"Voicemail_Detected\"\n    UNDETERMINED = \"Undetermined\"\n\nclass NextAction(StrEnum):\n    NONE = \"None\"\n    RETARGET_LATER = \"Retarget_Later\"\n    SALES_HANDOFF = \"Sales_Handoff\"\n\ndef get_hubspot_outcome_id(disposition: str, environment: Environment) -> str | None\n----\n\n`PRODUCTION_OUTCOME_IDS` and `PREVIEW_OUTCOME_IDS` are separate tables selected by\n`HUBSPOT_ENVIRONMENT`. Two things to know: `Qualified_Lead`, `Amazon_Seller`, and\n`Sales_Handoff` all map to the same GUID (Warm Lead / AE Follow Up), and\n`Voicemail_Detected` and `Undetermined` have no mapping — the caller omits\n`hs_call_disposition` when the lookup returns `None`.\n\n== Tasks\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Function | Purpose\n\n| `create_crm_task(client, props, contact_id=…, company_id=…)` | Create a Task with associations; returns the HubSpot task id\n| `get_crm_task(client, task_id)` | Read task properties; raises `NotFoundException` when deleted\n| `update_crm_task(client, task_id, props)` | PATCH a task\n| `create_incoming_email_followup_task(...)` | The specialised task `Crm.NewActivity` creates for an incoming email\n|===\n\n[WARNING]\n====\n`hubspot_owner_id` must be the Owner `id` from `GET /crm/v3/owners`. The number in a HubSpot\nsettings URL (`.../users/user/60733684/...`) is a *user* id and is not guaranteed to match.\nDo not use contact ids, company ids, or email addresses. A wrong, invalid, or out-of-scope id\noften still creates the task but assigns it to another user — usually the integration user.\nThis is documented at length in `tasks.py`.\n====\n\n== Lists\n\n`create_contact_list()`, `delete_list()`, and `build_call_list_filter_branch()` back the daily\nper-rep call list. The `filterBranch` payload is a generic `oneOf` on the SDK's\n`ListCreateRequest`, so it is hand-built to match HubSpot's list-filters schema and passed\nthrough as a dict.\n\nSee xref:business:call-lists.adoc#materialisation[list materialisation].\n\n== Notes\n\n`log_note(client, …)` creates a CRM Note associated with a Company and/or Contact in one API\ncall. Morpheus logs a Note whenever a sync *updated* an existing record, so the timeline shows\nthat automation touched it.\n\n== Association type ids\n\nHubSpot-defined ids used for v3 inline associations:\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Association | Type id\n\n| Email → Contact | 198\n| Email → Company | 186\n| Note → Contact | 202\n| Note → Company | 190\n|===\n\nVerify against the Associations API if an association starts failing — these are portal-level\nconstants that HubSpot documents but can extend.\n\n== Communication preferences\n\n`apply_subscription_preference_updates()` delegates to\n`HubSpotHttpClient.post_communication_preference_subscription_statuses()`, hitting\n`+POST /communication-preferences/2026-03/statuses/{email}+`. HubSpot may require one POST per\nsubscription type, so the list is applied item by item.\n\nReference:\nhttps://developers.hubspot.com/docs/api-reference/latest/communication-preferences/guide[HubSpot Communication Preferences].\n\n== Tests\n\n[source,bash]\n----\ncd libs/pyb-hubspot && uv run pytest tests -v\n----\n\n`test_contact_search.py`, `test_contact_properties.py`, `test_lists.py`,\n`test_http_client.py`, `test_communications_preferences.py` — all against a mocked SDK.\n"},{"component":"morpheus","module":"data","path":"data:index.adoc","title":"Data","description":"The two data planes Morpheus reads and writes — PostgreSQL for operations, BigQuery for segmentation.","url":"https://docx.payability.com/morpheus/data/index.html","navOrder":57,"source":"= Data\n:description: The two data planes Morpheus reads and writes — PostgreSQL for operations, BigQuery for segmentation.\n\nMorpheus works across two data planes, and knowing which one a rule reads from explains most\nof its behaviour.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Plane | Store | Role\n\n| OLTP | PostgreSQL (Supabase) | The operational database, shared with the Payability web application. Every write Morpheus makes to its own state lands here.\n| OLAP | BigQuery | A periodic export of the HubSpot portal plus a mirror of application tables. Every re-engagement segment and priority score is computed here.\n|===\n\n[IMPORTANT]\n====\nRe-engagement handlers *read* from BigQuery and *write* to HubSpot; PostgreSQL is consulted\nonly to resolve identity and ownership. BigQuery is an export, so a segment can lag reality by\nthe export interval — a contact who booked a deal five minutes ago may still appear in a\nsegment.\n====\n\n== Pages\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Page | Contents\n\n| xref:domain-model.adoc[Domain model] | The core entities and how they relate\n| xref:orm-models.adoc[ORM models] | The 13 SQLAlchemy models and their conventions\n| xref:repositories.adoc[Repositories] | Every repository method, with signatures\n| xref:enums.adoc[Enumerations] | All 20 `StrEnum` mirrors of the database enums\n| xref:configs-table.adoc[Runtime state in `configs`] | Watermarks, cursors, and kill switches\n| xref:warehouse.adoc[BigQuery warehouse] | Datasets, export shape, and deduplication\n| xref:bigquery-views.adoc[BigQuery views] | All 11 views, with purpose and consumer\n| xref:prisma-schema.adoc[Prisma schema reference] | The full authoritative schema\n|===\n\n== Schema ownership\n\nThe PostgreSQL schema is owned by the *web application's* Prisma schema, not by this\nrepository. There are no migrations here.\n\nConsequences:\n\n* A column Morpheus needs must be added to the Prisma schema first, migrated there, and only\n  then mapped in `morpheus_data_layer`.\n* The ORM mirrors the schema but does not define it. `ForeignKey(\"subscriptions.id\")` is\n  deliberately *not* declared — that table is not managed here and the constraint is enforced\n  at the database level only.\n* Attribute names are camelCase and match the column exactly, so a model is a faithful,\n  greppable reflection of the schema.\n\nThe current schema is reproduced verbatim at\nxref:prisma-schema.adoc[Prisma schema reference].\n\n== Tables Morpheus writes\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Table | Written by\n\n| `sellers` | enrichment-scorer (upsert, enrichment, score), morpheus-hubspot (`hubspotCompanyId`)\n| `contacts` | enrichment-scorer, enrichment-scraper (BigQuery sync), morpheus-hubspot (ids, lifecycle, heat)\n| `target_emails` | enrichment-scraper (BigQuery sync), morpheus-hubspot (opt-out webhook)\n| `target_phone_numbers` | enrichment-scraper (BigQuery sync), phone-agent (Twilio intel), morpheus-hubspot (Five9)\n| `Call` | morpheus-hubspot (dialer sync, Five9 sync)\n| `CampaignTarget` | morpheus-hubspot (engagement id, sync status, FK repair)\n| `Activity` | morpheus-hubspot (engagement sync, task logs), phone-agent (reminders), enrichment-scraper (lifecycle notes)\n| `follow_up_tasks` | morpheus-hubspot (create, status), phone-agent (escalation)\n| `sales_reps` | morpheus-hubspot (`hubspotCallSegmentId`)\n| `configs` | enrichment-scraper (cursor), morpheus-hubspot (watermarks), round-robin (cursor)\n| `email_campaign_targets` | email-agent (categorisation)\n| `emails` | email-agent (category)\n|===\n\nEverything else in the schema — campaigns, sequences, phone numbers, domains, AI agent\nconfiguration, profiles — belongs to the web application. Morpheus reads some of it and writes\nnone of it.\n\n== Tenancy\n\nEvery domain table carries `subscriptionId`. Production runs a single subscription,\n`{subscription-id}`. Four of the five functions read it from an environment variable and reject\nmismatched events; `email-agent` derives it from the row it loaded. See\nxref:ROOT:security.adoc#tenant-isolation[tenant isolation].\n\n== Connection handling\n\nAll five Lambdas call `init(database_url, pool_size=0)` at cold start, which selects\nSQLAlchemy's `NullPool`, and the connection string must be the Supabase *Transaction mode*\npooler URL (port `6543`). The reasoning is in\nxref:libs:morpheus-data-layer.adoc#pooling[pooling] — it matters enough that getting it wrong\nexhausts the database's connection limit under concurrency.\n"},{"component":"morpheus","module":"data","path":"data:domain-model.adoc","title":"Domain model","description":"The core Morpheus entities, how they relate, and the identity rules that hold them together.","url":"https://docx.payability.com/morpheus/data/domain-model.html","navOrder":58,"source":"= Domain model\n:description: The core Morpheus entities, how they relate, and the identity rules that hold them together.\n\n== Entity map\n\n[mermaid]\n....\nerDiagram\n  Subscription ||--o{ Seller : owns\n  Subscription ||--o{ Contact : owns\n  Subscription ||--o{ SalesRep : owns\n  Subscription ||--o{ Campaign : owns\n  Subscription ||--o{ Configs : owns\n\n  Seller ||--o{ Contact : \"employs\"\n  Contact ||--o{ TargetPhoneNumbers : \"reachable at\"\n  Contact ||--o{ TargetEmails : \"reachable at\"\n  Contact ||--o{ CampaignTarget : \"enrolled as\"\n  Contact ||--o{ EmailCampaignTarget : \"enrolled as\"\n  Contact ||--o{ Activity : \"has\"\n  Contact ||--o{ FollowUpTask : \"subject of\"\n\n  Campaign ||--o{ CampaignTarget : contains\n  Campaign ||--o{ EmailCampaignTarget : contains\n  Campaign ||--o{ EmailVariation : has\n\n  CampaignTarget ||--o{ Call : produced\n  CampaignTarget ||--o{ FollowUpTask : triggered\n\n  SalesRep ||--o{ FollowUpTask : \"assigned\"\n  SalesRep ||--o{ Call : made\n\n  EmailCampaignTarget ||--o{ Emails : \"messages\"\n  FollowUpTask ||--o{ Activity : logged\n....\n\n== The entities that matter\n\nSubscription — the tenant::\nEvery domain row carries `subscriptionId`. Production runs one:\n`{subscription-id}`. The `subscriptions` table itself is not managed by\n`morpheus-data-layer`, which is why no ORM model declares a foreign key to it.\n\nSeller — a marketplace business::\nMaps to a HubSpot *Company*, not a Contact. Keyed by `sellerId`, the external marketplace\nidentifier (`ATVPDKIKX0DER`). Carries marketplace metrics — `estimateSales`, `numberAsins`,\n`percentFba`, growth rates — plus enrichment output (`website`, `linkedinUrl`,\n`hubspotCompanyId`) and `payoutPainScore`.\n+\n`platform` is `amazon`, `walmart`, or `shopify`. `datasource` is `smartscout` or `manual`.\n\nContact — a person::\nThe single source of truth shared between campaign targets and HubSpot. Optionally linked to a\n`Seller`. Carries identity, address, lifecycle and lead status, calling context\n(`disposition`, `stopCalling`, `nextAction`), HubSpot sync fields, and supplier fields\n(`supplierKey`, `supplierStatus`).\n+\nFour unique constraints, all per subscription: `email`, `phone`, `hubspotContactId`,\n`supplierKey`. Nullable-unique — many rows may have `NULL`, but a non-null value must be\nunique.\n\nTargetPhoneNumbers / TargetEmails — reachable endpoints::\nA contact may have several. These carry the *channel* state that does not belong on the\nperson: line type and risk level for phones, opt-out flags and send spacing for emails. This is\nwhere suppression actually lives.\n\nCampaignTarget — one phone number in a calling campaign::\nIts own state machine plus a cache of business-logic answers (`isAmazonSeller`,\n`meetsMonthlyThreshold`, `interestedInDailyPayments`, `stopCalling`, `disposition`) and\nscheduling fields (`nextEarliestAttemptAt`, `leaseExpiresAt`).\n\nCall — one call attempt::\nWritten by both dialer paths. Holds identifiers, status and timing, cost and latency, AI\nanalysis (sentiment, disposition, intelligence flags, extracted business logic), actionable\nnext steps, and HubSpot correlation. `providerCallId` is unique when set, which is what makes\nthe Five9 upsert idempotent.\n\nActivity — a timeline entry::\n`sms`, `email`, `call`, `task`, `meeting`, `note`, or `reminder`. Unique on\n`(subscriptionId, externalId)`, which is how the BigQuery engagement sync upserts HubSpot\nengagements idempotently.\n\nFollowUpTask — a commitment by a rep::\nMirrored into HubSpot and optionally Nooks. Carries `escalationStage` for the Slack ladder. See\nxref:business:follow-up-tasks.adoc[Follow-up tasks and escalation].\n\nSalesRep — a human who calls::\nSubscription-scoped, so the same person can be a rep in several workspaces with different CRM\nids. Holds `hubspotOwnerId`, `nooksUserId`, `activeListId`, and `hubspotCallSegmentId`.\n`isActive` gates every selector.\n\nConfigs — runtime state::\nA key/value table used for watermarks, pagination cursors, round-robin position, and kill\nswitches. `key` is globally unique, not unique per subscription — which is why the round-robin\nkey embeds the subscription id. See xref:configs-table.adoc[Runtime state in `configs`].\n\n== Identity and matching\n\nThe hardest problem in the platform is deciding that two records are the same person.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Entity | Natural key | HubSpot correlation\n\n| Seller | `sellerId` (marketplace id) | `hubspotCompanyId`; search falls back to the `amazon_seller_id` property, then `name`\n| Contact | `email`, then `phone` | `hubspotContactId`; search is phone-first, then email and mobile\n| Call | `providerCallId` | `hubspotEngagementId`\n| Activity | `(subscriptionId, externalId)` | `externalId` is the HubSpot `engagement_id`\n| FollowUpTask | `id` | `hubspotTaskId`\n| SalesRep | `(subscriptionId, email)` | `hubspotOwnerId`\n|===\n\nThree mechanisms keep identity converging:\n\nPhone-first resolution::\n`resolve_hubspot_contact_id_phone_first()` tries phone, then email, then mobile phone, and\nreports which step matched plus whether there were multiple matches. Phone first because a\ndialer knows a number and may not know an email.\n\nContact backfill::\n`ensure_local_contact_row()` guarantees a local row exists for a HubSpot contact id,\nshort-circuiting through: already loaded → local lookup by HubSpot id → fetch from the CRM and\nupsert → repair the campaign target's foreign keys.\n\nScheduled id resolution::\n`Hubspot.ContactResolve` widens the search to every related `target_phone_numbers` and\n`target_emails` row, and stamps `hubspotLastSyncedAt` even on a miss so the batch keeps moving\nthrough fresh candidates.\n\n[NOTE]\n====\nThe email-click BigQuery view deliberately resolves HubSpot contacts *by email* rather than by\n`contacts.hubspotcontactid`, because that mirror column is frequently null or stale at query\ntime. Keying the joins on it let rows through unlinked, so suppression checks silently missed.\nThe view's header comment documents this at length.\n====\n\n== Two lifecycle ladders\n\n`Seller.lifeCycleStage` — the internal enrichment pipeline::\n`new` → `raw_lead` → `enriched` → `in_sequence` → `engaged` → `qualified` → `bdr_handoff` →\n`demo_scheduled` → `opportunity` → `customer`, plus `disqualified`. Morpheus writes only `new`\nand `enriched`.\n\n`Contact.lifecycleStage` — a mirror of HubSpot's `lifecyclestage`::\n`subscriber` → `lead` → `marketingqualifiedlead` → `salesqualifiedlead` → `opportunity` →\n`customer` → `evangelist`, plus `other`. Morpheus only ever *promotes*, via a rank guard. See\nxref:business:funnel.adoc[Outbound funnel].\n\nAlongside them, two columns act as pipeline gates rather than stages: `enrichedAt` (enrichment\nhas run) and `payoutPainScore` (scoring has run). Both are `IS NULL` checks in the backfill\nqueries.\n\n== Heat status\n\n`Contact.heatStatus` is a coarse engagement temperature:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Value | Meaning\n\n| `hot` | Active follow-up in progress\n| `warm` | A rep acted but the deal did not close\n| `warming` | The task failed; an automated email track is running\n| `idle` | 30 days of silence, deprioritised\n|===\n\nMorpheus writes `hot` in one place: after the dialer sync creates a follow-up task. The other\ntransitions belong to the web application.\n"},{"component":"morpheus","module":"data","path":"data:orm-models.adoc","title":"ORM models","description":"The 13 SQLAlchemy models, the conventions they follow, and how they map to PostgreSQL enums.","url":"https://docx.payability.com/morpheus/data/orm-models.html","navOrder":59,"source":"= ORM models\n:description: The 13 SQLAlchemy models, the conventions they follow, and how they map to PostgreSQL enums.\n\nSQLAlchemy 2.0 declarative models in\n`libs/morpheus-data-layer/morpheus_data_layer/models/`, one per file, all on a shared `Base`,\nall exported from `models/__init__.py`.\n\nThe models *mirror* a schema owned elsewhere — they do not define it. Every model docstring\nsays so: \"Read/write only — schema modifications are not permitted via this layer.\"\n\n== The models\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Model | Table | Role\n\n| `Seller` | `sellers` | A marketplace business; maps to a HubSpot Company\n| `Contact` | `contacts` | A person; the single source of truth shared with HubSpot\n| `Activity` | `Activity` | A timeline entry — sms, email, call, task, meeting, note, reminder\n| `Call` | `Call` | One call attempt, AI or human\n| `CampaignTarget` | `CampaignTarget` | One phone number in a calling campaign, with its state machine\n| `Configs` | `configs` | Key/value runtime state\n| `Email` | `emails` | An individual message, inbound or outbound\n| `EmailCampaignTarget` | `email_campaign_targets` | One (campaign, contact) pair in an email campaign\n| `EmailVariation` | `email_variations` | Content variant for an email campaign\n| `FollowUpTask` | `follow_up_tasks` | A commitment assigned to a rep\n| `SalesRep` | `sales_reps` | A human who calls\n| `TargetEmails` | `target_emails` | An email address plus its suppression state\n| `TargetPhoneNumber` | `target_phone_numbers` | A phone number plus its line intelligence\n|===\n\nNote the table naming is inconsistent — `sellers` and `contacts` are lowercase plural, while\n`Activity`, `Call`, and `CampaignTarget` are PascalCase singular. That reflects the upstream\nPrisma schema, where some models declare `@@map` and some do not. The ORM matches whatever the\ndatabase actually has.\n\n== Conventions\n\n=== camelCase attributes, no overrides\n\n[source,python]\n----\nclass Seller(Base):\n    __tablename__ = \"sellers\"\n\n    id: Mapped[str] = mapped_column(String, primary_key=True,\n                                    default=lambda: str(uuid.uuid4()))\n    subscriptionId: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)\n    lifeCycleStage: Mapped[SellerLifeCycleStage] = mapped_column(\n        _seller_lifecycle_stage_pg,\n        server_default=text(\"'new'::\\\"SellerLifeCycleStage\\\"\"),\n        nullable=False,\n    )\n    hubspotCompanyId: Mapped[str | None] = mapped_column(String, nullable=True)\n----\n\nThe Python attribute name *is* the column name. No `mapped_column(name=...)` anywhere.\n\nThis is unusual for SQLAlchemy — the idiomatic choice is snake_case attributes mapped onto\nwhatever the column is called. Doing it this way means:\n\n* `conflict_columns=[\"sellerId\"]` and `update_columns=[...]` in an upsert are literal column\n  names, and grep finds every use of a column across Python and SQL alike.\n* Raw SQL, BigQuery view columns, and ORM attributes all read the same.\n* The cost is that Python code is full of camelCase identifiers, which no linter will like.\n\n=== No `subscriptions` foreign key\n\n`ForeignKey(\"subscriptions.id\")` is deliberately *not* declared on any model. That table is not\nmanaged by this layer; the constraint exists at the database level only. Adding it would make\nthe ORM claim ownership of a table it does not own.\n\nOther foreign keys *are* declared — `Contact.sellerId` references `sellers.id`, for example.\n\n=== PostgreSQL enums with `create_type=False`\n\n[source,python]\n----\n_seller_platform_pg = ENUM(\n    *[e.value for e in SellerPlatform],\n    name=\"SellerPlatform\",      # must match the existing DB type name exactly\n    create_type=False,          # never attempt to CREATE TYPE\n)\n----\n\nEach model builds its `ENUM` types from the corresponding `StrEnum` in\n`morpheus_data_layer/enums.py`, so the Python enum and the database type cannot drift in\ntheir *values*. The `name` must match the database type exactly, and `create_type=False`\nguarantees the ORM never tries to create or alter a type — a migration concern that belongs to\nPrisma.\n\nAdding a value to a database enum therefore requires: change the Prisma schema, migrate, then\nadd the member to the `StrEnum` here.\n\n=== Defaults\n\nServer-side where the database has one:\n\n[source,python]\n----\nlifeCycleStage: Mapped[SellerLifeCycleStage] = mapped_column(\n    _seller_lifecycle_stage_pg,\n    server_default=text(\"'new'::\\\"SellerLifeCycleStage\\\"\"),\n)\ncreatedAt: Mapped[datetime] = mapped_column(DateTime(timezone=True),\n                                            server_default=func.now())\n----\n\nClient-side only for generated primary keys (`default=lambda: str(uuid.uuid4())` or\n`uuid.uuid4`). Note the primary-key types differ by table: `Seller.id` is a `String`,\n`Contact.id` is a `UUID(as_uuid=True)` — again mirroring Prisma, where `Seller.id` lacks\n`@db.Uuid`.\n\n=== Nullable-unique columns\n\n`Contact.email` and `Contact.phone` are *nullable-unique*: many rows may hold `NULL`, but a\nnon-null value must be unique per subscription. PostgreSQL treats `NULL` as distinct in a\nunique index, so this works without a partial index.\n\nThe practical consequence appears in the BigQuery contact sync, which drops an email or phone\nfrom an update when it would collide with a different contact — see\nxref:lambdas:enrichment-scraper.adoc[enrichment-scraper].\n\n=== Indexes and relationships\n\nIndexes are declared with `Index(...)` in `__table_args__`, mirroring the Prisma `@@index`\ndeclarations. Relationships are declared with `relationship()` where a handler traverses them —\n`FollowUpTask.salesRep` and `FollowUpTask.contact` are used by the escalation handler,\n`Call.target` by the dialer sync. Most models declare few relationships, because most access\ngoes through explicit repository queries instead.\n\n[WARNING]\n====\n`expire_on_commit=False` on the session factory keeps *scalar* attributes readable after a\ncommit, but a relationship or an unloaded column raises when accessed on a detached instance.\nThat is why `_build_crm_sync_event()` in `enrichment-scorer` runs inside the session, and why\n`_upsert_seller()` explicitly touches `enrichedAt`, `payoutPainScore`, and `note` before the\nblock ends.\n====\n\n== Removed columns\n\nTwo columns that used to exist on `Seller` and no longer do — both are listed in `AGENTS.md`\nas common mistakes:\n\n* `confirmed_email`\n* `hubspotContactId` — a seller maps to a HubSpot *Company*; `hubspotCompanyId` is the correct\n  field. Contact ids belong on `Contact`.\n\n== Adding a model\n\n. Add the table to the web application's Prisma schema and migrate it there first.\n. Create `models/<name>.py` with camelCase attributes matching the columns exactly.\n. Build any `ENUM` types from `morpheus_data_layer/enums.py` with `create_type=False`.\n. Do not declare a `subscriptions` foreign key.\n. Export the model from `models/__init__.py`.\n. Add a repository — see xref:repositories.adoc[Repositories].\n. Update xref:prisma-schema.adoc[Prisma schema reference] and this page.\n\n== Full schema\n\nThe authoritative definition, including every table Morpheus does *not* model:\nxref:prisma-schema.adoc[Prisma schema reference].\n"},{"component":"morpheus","module":"data","path":"data:repositories.adoc","title":"Repositories","description":"Every repository class in morpheus-data-layer, with method signatures, upsert keys, and transaction semantics.","url":"https://docx.payability.com/morpheus/data/repositories.html","navOrder":60,"source":"= Repositories\n:description: Every repository class in morpheus-data-layer, with method signatures, upsert keys, and transaction semantics.\n\nRepositories are the only sanctioned way to reach the database. They live in\n`libs/morpheus-data-layer/morpheus_data_layer/repositories/`, one file per model, and every\npublic name is re-exported from `repositories/__init__.py`.\n\nTwo rules govern all of them:\n\nRepositories are stateless::\nInstantiate once at module level — `_repo = SellerRepository()` — and reuse across\ninvocations. There is no per-request construction cost and no instance state to leak between\nLambda invocations.\n\nRepositories never own a transaction::\nEvery method takes an explicit `Session` as its first positional argument. No method calls\n`commit()`, `rollback()`, or opens a session. The caller does that with\n`with get_session() as session:`, which commits on a clean exit and rolls back on an\nexception. A handler that calls three repositories inside one `with` block gets one atomic\ntransaction across all three.\n\n[source,python]\n----\nfrom morpheus_data_layer.repositories import SellerRepository, ContactRepository\nfrom morpheus_data_layer.session import get_session\n\n_seller_repo = SellerRepository()\n_contact_repo = ContactRepository()\n\nwith get_session() as session:\n    seller = _seller_repo.upsert_by_seller_id(session, data)\n    _contact_repo.upsert_for_seller(session, contact_data)\n    # commit happens here, once, for both writes\n----\n\n[#base]\n== BaseRepository\n\n`repositories/base.py` — a generic `BaseRepository[ModelT]`. A subclass binds it to a model\nwith a single class attribute:\n\n[source,python]\n----\nclass SellerRepository(BaseRepository[Seller]):\n    model_class = Seller\n----\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_id(session, record_id)`\n| Primary-key lookup. Returns `None` when absent.\n\n| `get_many_by_ids(session, record_ids)`\n| `WHERE pk IN (...)`. Raises `ValueError` on a composite primary key.\n\n| `get_all(session, *, limit=100, offset=0)`\n| Paginated full scan.\n\n| `filter_by(session, **kwargs)`\n| Equality filters on mapped attributes. Returns a list.\n\n| `first_by(session, **kwargs)`\n| Same filters, first match or `None`.\n\n| `insert(session, data)`\n| `add` + `flush` + `refresh`. Returns the populated instance; caller commits.\n\n| `bulk_insert(session, rows)`\n| Multi-row insert with no ORM instances returned.\n\n| `upsert(session, data, conflict_columns, update_columns=None)`\n| PostgreSQL `INSERT … ON CONFLICT DO UPDATE`, returning the row via `RETURNING`.\n\n| `update(session, record, data)`\n| `setattr` each key, then `flush` + `refresh`.\n\n| `update_by_id(session, record_id, data)`\n| Fetch then update. Returns `None` if the row does not exist.\n|===\n\n[IMPORTANT]\n====\n`BaseRepository` has **no** `delete` and no `count`. Morpheus does not delete rows — records\nare soft-signalled through status columns (`isActive`, `isDeleted`, lifecycle stages) so that\nhistory survives. If you need a count, write a scalar query in the concrete repository.\n====\n\n=== The upsert contract\n\n`upsert()` is how every idempotent write is done, because SQS delivers at-least-once and\nschedules overlap.\n\n[source,python]\n----\nrepo.upsert(\n    session,\n    data,\n    conflict_columns=[\"sellerId\"],                     # <1>\n    update_columns=[\"businessName\", \"enrichedAt\"],     # <2>\n)\n----\n<1> Must match a real unique constraint or unique index, written as the **camelCase database\ncolumn name**.\n<2> Optional. When omitted, every key in `data` except the conflict columns is updated.\n\nBecause ORM attribute names are identical to column names\n(see xref:orm-models.adoc[ORM models]), these lists are literal SQL identifiers — grep finds\nevery place a column participates in a conflict resolution.\n\n== Repository reference\n\n=== SellerRepository\n\n`repositories/seller.py` → `Seller`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_seller_id(session, seller_id)`\n| Lookup by the external marketplace id, not the primary key.\n\n| `get_by_platform(session, platform, *, limit=100, offset=0)`\n| Filter on `SellerPlatform`, newest `createdAt` first.\n\n| `get_by_subscription(session, subscription_id, *, limit=100, offset=0)`\n| Tenant-scoped listing, newest first.\n\n| `get_pending_enrichment(session, *, limit=10)`\n| `lifeCycleStage == NEW` and `enrichedAt IS NULL`, oldest first.\n\n| `get_pending_scoring(session, *, limit=10)`\n| `enrichedAt IS NOT NULL` and `payoutPainScore IS NULL`, oldest `enrichedAt` first.\n\n| `get_suspended(session, *, limit=100, offset=0)`\n| `isSuspended` is true.\n\n| `upsert_by_seller_id(session, data)`\n| Upsert on `sellerId`, updating every key in `data` except `sellerId`, `id`, `createdAt`.\n|===\n\nThe two `get_pending_*` methods are the queue for the backfill schedules — they define what\n\"pending\" means for xref:business:seller-enrichment.adoc[enrichment] and\nxref:business:payout-pain-score.adoc[scoring], and the ordering is what makes the backfill\nfair rather than starving the oldest rows.\n\n=== ContactRepository\n\n`repositories/contact.py` → `Contact`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_email(session, email)`\n| Exact match on the nullable-unique `email` column.\n\n| `get_by_phone(session, phone)`\n| Exact match on the nullable-unique `phone` column.\n\n| `get_by_hubspot_contact_id(session, hubspot_id)`\n| Match on the CRM identifier.\n\n| `get_by_seller(session, seller_id, *, limit=100, offset=0)`\n| All contacts of one seller, newest first.\n\n| `list_missing_hubspot_contact_id(session, *, subscription_id, limit)`\n| Contacts with no `hubspotContactId` that have an email, phone, or mobile phone. Oldest first.\n\n| `list_missing_hubspot_id_by_last_sync(session, *, subscription_id, limit=25)`\n| Same population, ordered `hubspotSyncPriority DESC`, then `hubspotLastSyncedAt ASC NULLS FIRST`.\n\n| `find_existing(session, *, email, phone, hubspot_contact_id)`\n| Identity resolution. Tries `hubspotContactId`, then `email`, then `phone`, in that order.\n\n| `upsert_for_seller(session, data)`\n| Find-or-create with merge semantics — see below.\n|===\n\n[WARNING]\n====\n`upsert_for_seller()` is **not** a PostgreSQL upsert. It resolves an existing contact through\n`find_existing()`, then merges — and it will never overwrite a non-null column with `NULL`,\nand it strips an incoming `email` or `phone` that would collide with a *different* contact\nrow.\n\nThat behaviour exists because `Contact.email` and `Contact.phone` are nullable-unique\nglobally. A naive `ON CONFLICT` would either raise on the collision or silently blank out a\nverified phone number with an empty scrape result. The merge rule is the whole reason\nenrichment can run repeatedly against the same contact without degrading it.\n====\n\nThe two `list_missing_*` methods differ only in ordering, and both exist on purpose:\n`Hubspot.ContactResolve` uses the priority-ordered one so that high-value contacts reach the\nCRM first, while the age-ordered one guarantees nothing is stranded forever. See\nxref:business:crm-sync.adoc[CRM sync].\n\n=== ActivityRepository\n\n`repositories/activity.py` → `Activity`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_external_id(session, *, subscription_id, external_id)`\n| Lookup on the unique `(subscriptionId, externalId)` pair.\n\n| `list_by_contact(session, contact_id, *, activity_type=None, limit=100, offset=0)`\n| Timeline for one contact, newest first, optionally filtered by `ActivityType`.\n|===\n\nActivity upserts are performed by the caller rather than the repository. The BigQuery\nengagement sync calls the base `upsert()` directly with:\n\n[source,python]\n----\nconflict_columns=[\"subscriptionId\", \"externalId\"]\nupdate_columns=[\"type\", \"hubspotEngagementId\", \"subject\", \"body\", \"status\", \"direction\",\n                \"contactId\", \"contactEmail\", \"hubspotContactId\", \"metadata\", \"updatedAt\"]\n----\n\n`externalId` is the HubSpot engagement id, which is what makes re-running the sync over an\noverlapping watermark window free of duplicates. See\nxref:events:scheduled.adoc[Scheduled events].\n\n=== CallRepository\n\n`repositories/call.py` → `Call`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_provider_call_id(session, provider_call_id)`\n| Lookup by the dialer's own call identifier.\n\n| `get_with_target_for_subscription(session, call_id, subscription_id)`\n| Tenant-scoped fetch with `joinedload(Call.target)`.\n\n| `get_with_target_by_ids(session, call_id, target_id, subscription_id)`\n| Same, but also asserts the target id — defence in depth against a mismatched pair in a webhook.\n|===\n\nThe Five9 sync upserts through the base method on `conflict_columns=[\"providerCallId\"]`,\nupdating `targetPhoneNumber`, `direction`, `status`, `disposition`, `durationMs`,\n`durationSec`, `startTimestamp`, `hubspotEngagementId`, `hubspotSyncStatus`, `contactName`,\n`notes`, `targetPhoneNumbersId`, and `salesRepId`. See\nxref:business:call-logging.adoc[Call logging and dispositions].\n\n=== SalesRepRepository\n\n`repositories/sales_rep.py` → `SalesRep`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_active_by_email(session, email, subscription_id)`\n| Active rep matching an email address.\n\n| `get_active_by_hubspot_owner_id(session, hubspot_owner_id, subscription_id)`\n| Active rep matching a HubSpot owner id.\n\n| `get_by_hubspot_owner_id(session, hubspot_owner_id, subscription_id)`\n| Same lookup **without** the `isActive` filter.\n\n| `list_active_for_subscription(session, subscription_id, *, require_hubspot=True, require_nooks=False)`\n| Active reps, ordered `id ASC`, optionally requiring a HubSpot owner id or a Nooks id.\n|===\n\n[NOTE]\n====\n`get_by_hubspot_owner_id()` has a docstring that says \"active\", but the query carries no\n`isActive` predicate. It resolves a rep for an *inbound* record — a call or engagement already\nowned by someone who may since have been deactivated — where dropping the row would lose\nhistory. `get_active_by_hubspot_owner_id()` is the one to use when *assigning* new work.\n====\n\nThe `id ASC` ordering in `list_active_for_subscription()` is load-bearing: it is the stable\nordering that makes the round-robin cursor below meaningful.\n\n[#round-robin]\n=== Round-robin helpers\n\n`repositories/sales_rep_round_robin.py`. Functions, not a class, because the state they\nmanage lives in the `configs` table rather than in a model.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Name | Behaviour\n\n| `RR_CONFIG_KEY_PREFIX`\n| The constant `\"dialer_follow_up_rep_rr\"`.\n\n| `dialer_rr_config_key(subscription_id)`\n| Builds `dialer_follow_up_rep_rr:<subscriptionId>`.\n\n| `reserve_sales_rep_round_robin_batch(session, subscription_id, *, require_nooks, count, sales_rep_repo=None, configs_repo=None)`\n| Reserves `count` reps and advances the cursor by `count`. Returns `[]` when no rep qualifies.\n\n| `select_next_sales_rep_round_robin(session, subscription_id, *, require_nooks, sales_rep_repo=None, configs_repo=None)`\n| `count=1` convenience wrapper.\n|===\n\nThe batch function takes a **PostgreSQL transaction-level advisory lock** before reading the\ncursor. Without it, two concurrent re-engagement handlers would read the same cursor value and\nassign the same rep to both batches, defeating the point of round-robin. The lock is released\nwhen the transaction ends, so it must be held inside the caller's `with get_session()` block.\n\nReserving a batch and advancing once — rather than calling the single-rep helper in a loop —\nkeeps the lock window short and the distribution even. Full rules in\nxref:business:ownership.adoc[Ownership and round-robin].\n\n=== ConfigsRepository\n\n`repositories/configs.py` → `Configs`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_key(session, key)`\n| Row lookup on the globally unique `key`.\n\n| `get_value(session, key)`\n| The `value` string, or `None`.\n\n| `get_by_subscription(session, subscription_id, *, limit=100, offset=0)`\n| All rows for one tenant.\n\n| `set_value(session, key, value, subscription_id)`\n| Upsert with `conflict_columns=[\"key\"]`, `update_columns=[\"value\"]`.\n|===\n\nEvery key in use is catalogued in xref:configs-table.adoc[Runtime state in `configs`].\n\n=== FollowUpTaskRepository\n\n`repositories/follow_up_task.py` → `FollowUpTask`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `list_pending_due_soon(session, subscription_id, window_hours=24)`\n| `PENDING` tasks due within the window. Eager-loads `salesRep`.\n\n| `list_pending_with_hubspot_task(session, subscription_id, limit=50)`\n| `PENDING` or `FAILED` tasks that carry a `hubspotTaskId`, `FAILED` first.\n\n| `has_active_task_for_contact(session, *, contact_id, subscription_id, task_type=FollowUpTaskType.CALL)`\n| True when a `PENDING` or `IN_PROGRESS` task exists that is not past due.\n\n| `list_stale_for_escalation(session, subscription_id)`\n| `PENDING`, due at least a day ago, `escalationStage < 3`. Eager-loads `salesRep` and `contact`.\n|===\n\n`has_active_task_for_contact()` is the deduplication guard every re-engagement handler calls\nbefore creating work — it is what stops four segments from putting the same contact on a rep's\nlist four times. The `escalationStage < 3` ceiling caps escalation at three notifications.\nSee xref:business:follow-up-tasks.adoc[Follow-up tasks and escalation].\n\nThe eager loads matter: `expire_on_commit=False` keeps scalars readable after commit but\nraises on an unloaded relationship, so a handler that formats a Slack message from\n`task.salesRep.email` after the session closes needs the relationship already loaded.\n\n=== Email repositories\n\n[cols=\"1,2,3\",options=\"header\"]\n|===\n| Repository | Model | Methods beyond `BaseRepository`\n\n| `EmailRepository`\n| `Email`\n| None — a plain model wrapper.\n\n| `EmailVariationRepository`\n| `EmailVariation`\n| None — a plain model wrapper.\n\n| `EmailCampaignTargetRepository`\n| `EmailCampaignTarget`\n| `get_by_campaign_and_email(session, *, campaign_id, email)` — case-insensitive email match. +\n  `update_categorisation(session, record, *, category, categorised_at, category_model)` — writes `category`, `categorisedAt`, `categoryModel`.\n\n| `TargetEmailsRepository`\n| `TargetEmails`\n| `get_by_subscription_and_email(session, *, subscription_id, email)` — case-insensitive. +\n  `set_newsletter_opt_out(session, *, subscription_id, email, opted_out, occurred_at)` — guarded write.\n|===\n\n`update_categorisation()` records `category_model` alongside the verdict, so a\nmisclassification can be traced back to the model that produced it — see\nxref:lambdas:email-agent.adoc[email-agent].\n\n`set_newsletter_opt_out()` compares `occurred_at` against the stored `newsletterOptOutAt` and\nignores the write when the incoming event is older. HubSpot webhooks arrive out of order, and\nwithout the guard a stale \"subscribed\" event could silently undo a later opt-out — a\ncompliance problem, not just a data problem.\n\n=== TargetPhoneNumberRepository\n\n`repositories/target_phone_number.py` → `TargetPhoneNumber`\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Method | Behaviour\n\n| `get_by_phone_number(session, phone_number)`\n| Substring match (`phoneNumber.contains(...)`), tolerating format differences between sources.\n\n| `list_pending_twilio_sync(session, *, subscription_id, limit)`\n| `syncTwilio` false, active, not deleted. Ordered `syncPriority DESC`, then `createdAt ASC`.\n|===\n\n`list_pending_twilio_sync()` is the work queue for\nxref:business:phone-intelligence.adoc[phone line intelligence]; the batch size comes from\n`PHONE_AGENT_BATCH_SIZE`, and each Twilio Lookup call costs money, so the priority ordering\ndecides what the budget is spent on.\n\n== Exports\n\n`repositories/__init__.py` exports exactly:\n\n[source,python]\n----\nActivityRepository, BaseRepository, CallRepository, ConfigsRepository,\nContactRepository, EmailRepository, EmailCampaignTargetRepository,\nEmailVariationRepository, FollowUpTaskRepository, SalesRepRepository,\nSellerRepository, TargetEmailsRepository, TargetPhoneNumberRepository,\ndialer_rr_config_key, reserve_sales_rep_round_robin_batch,\nselect_next_sales_rep_round_robin\n----\n\nThere is no `CampaignTargetRepository`. `CampaignTarget` is mapped as a model and written\nthrough the base `upsert()` by `morpheus-hubspot` directly.\n\n== Adding a repository\n\n. Create `repositories/<name>.py` with `class XRepository(BaseRepository[X])` and\n  `model_class = X`.\n. Add only the queries a handler actually needs. Ad-hoc filtering belongs in `filter_by()`.\n. Take `session: Session` first, positionally; make everything else keyword-only.\n. Never commit, never open a session, never catch and swallow a database error.\n. Use camelCase column names in `conflict_columns` and `update_columns`.\n. Export it from `repositories/__init__.py`.\n. Update this page and xref:libs:morpheus-data-layer.adoc[morpheus-data-layer].\n"},{"component":"morpheus","module":"data","path":"data:enums.adoc","title":"Enumerations","description":"Every StrEnum in the data layer and the shared libraries, with exact member values.","url":"https://docx.payability.com/morpheus/data/enums.html","navOrder":61,"source":"= Enumerations\n:description: Every StrEnum in the data layer and the shared libraries, with exact member values.\n\nAll enumerations are `StrEnum`, so a member *is* its string value. That means a member can be\npassed straight into a SQL comparison, a JSON payload, or an f-string without `.value`, and\n`json.dumps()` serialises it without a custom encoder.\n\nThe database enums live in `libs/morpheus-data-layer/morpheus_data_layer/enums.py`. The ORM\nbuilds its PostgreSQL `ENUM` types from these classes with `create_type=False`\n(see xref:orm-models.adoc[ORM models]), so the Python members and the database type cannot\ndrift in their *values* — but they can drift in their *membership*.\n\n[IMPORTANT]\n====\nAdding a value is a three-step change in a fixed order: change the web application's Prisma\nschema, migrate the database, then add the member here. Adding it here first produces a\nruntime error on insert, because PostgreSQL rejects a value its type does not know.\n====\n\n== Seller\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `SellerPlatform`\n| `amazon`, `walmart`\n\n| `SellerDatasource`\n| `smartscout`, `manual`\n\n| `SellerLifeCycleStage`\n| `new`, `raw_lead`, `enriched`, `in_sequence`, `engaged`, `qualified`, `bdr_handoff`,\n  `demo_scheduled`, `opportunity`, `customer`, `disqualified`\n|===\n\n`SellerLifeCycleStage` is the ladder a business climbs from discovery to close. Only the first\nfew transitions are automated: xref:lambdas:enrichment-scraper.adoc[enrichment-scraper] creates\nrows at `new`, and xref:lambdas:enrichment-scorer.adoc[enrichment-scorer] advances them to\n`enriched`. Everything from `engaged` onward is set by human activity in HubSpot and mirrored\nback. See xref:business:funnel.adoc[Outbound funnel].\n\n[WARNING]\n====\nThe Prisma schema declares a *different* set for this enum — `new`, `enriched`, `qualified`,\n`opportunity`, `closed_won`, `closed_lost`. `SellerPlatform` also carries a `shopify` member\nin Prisma that is absent here. This is real drift; see\nxref:prisma-schema.adoc#drift[schema drift].\n====\n\n== Contact\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `ContactHubspotSyncStatus`\n| `synced`, `pending`, `failed`, `skipped`\n\n| `ContactSource`\n| `campaign`, `hubspot`, `import`, `manual`\n\n| `ContactHeatStatus`\n| `hot`, `warm`, `warming`, `idle`\n\n| `ContactLifecycleStage`\n| `subscriber`, `lead`, `marketingqualifiedlead`, `salesqualifiedlead`, `opportunity`,\n  `customer`, `evangelist`, `other`\n\n| `ContactLeadStatus`\n| `new`, `open`, `in_progress`, `open_deal`, `unqualified`, `attempted_to_contact`,\n  `connected`, `bad_timing`\n|===\n\n`ContactLifecycleStage` values are unpunctuated on purpose — `marketingqualifiedlead`, not\n`marketing_qualified_lead`. They are HubSpot's own internal values, copied verbatim so the\nmirror needs no translation table. `ContactLeadStatus`, by contrast, *is* snake_case and is\nmapped when written to the CRM.\n\n`ContactHeatStatus` is Morpheus's own engagement temperature, not a HubSpot concept. What\nearns each level is described in xref:business:crm-sync.adoc[CRM sync].\n\n[NOTE]\n====\n`Contact.source` is mapped as a plain `String` with a default of `\"manual\"`, not as this\n`ContactSource` enum. The values still come from `ContactSource`, but nothing enforces it at\nthe database level.\n====\n\n== Email\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `EmailTargetStatus`\n| `pending`, `requested`, `sent`, `opened`, `bounced`, `unsubscribed`, `blocked`, `failed`\n\n| `EmailTargetCategory`\n| `interested`, `not_interested`, `question`, `out_of_office`, `unsubscribe`, `wrong_person`,\n  `other`\n\n| `EmailDirection`\n| `sent`, `received`\n\n| `EmailInboxCategory`\n| `campaign`, `reply`, `transactional`, `other`\n\n| `EmailVariationStatus`\n| `pending_generation`, `active`, `archived`\n|===\n\n`EmailTargetCategory` is the output schema of the reply classifier — it is the\n`structured_output_model` the Bedrock call is constrained to, which is why the agent can never\ninvent a category. `other` is the fallback that keeps an unrecognised reply from failing the\nmessage. See xref:business:email-engagement.adoc[Email engagement].\n\n`bounced`, `unsubscribed`, and `blocked` in `EmailTargetStatus` are terminal suppression\nstates — see xref:business:call-lists.adoc#suppression[suppression].\n\n== Telephony\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `LineTypes`\n| `mobile`, `landline`, `fixedVoip`, `nonFixedVoip`, `tollFree`, `sharedCost`, `premium`,\n  `voicemail`, `pager`, `voip`, `uan`, `unknown`, `other`\n\n| `RiskLevel`\n| `low`, `medium`, `high`, `critical`\n\n| `CallStatus`\n| `created`, `dialing`, `answered`, `ended`\n\n| `TargetState`\n| `pending`, `in_progress`, `dialing`, `completed`, `failed`, `canceled`, `skipped`, `DNC`,\n  `WN`, `MAX`, `DT_PENDING`, `VOICEMAIL`\n|===\n\n`LineTypes` members are camelCase because they are Twilio Lookup's own line-type strings,\nstored unmodified. A `mobile` result is what makes a number SMS-eligible and worth a dial;\nsee xref:business:phone-intelligence.adoc[Phone line intelligence].\n\n`TargetState` mixes lowercase workflow states with uppercase dialer outcomes. The uppercase\nones are terminal and carry specific meanings: `DNC` do-not-call, `WN` wrong number, `MAX`\nmaximum attempts reached, `DT_PENDING` awaiting a dial-tone retry. The mixed casing is the\nupstream dialer's convention, preserved rather than normalised. The state machine is described\nin xref:business:call-lists.adoc[Daily call lists].\n\n== Tasks and activity\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `FollowUpTaskType`\n| `call`, `email`, `sequence_enrollment`\n\n| `FollowUpTaskStatus`\n| `pending`, `in_progress`, `completed`, `skipped`, `failed`\n\n| `ActivityType`\n| `sms`, `email`, `call`, `task`, `meeting`, `note`, `reminder`\n|===\n\n`FollowUpTaskStatus` distinguishes `skipped` from `failed`: `skipped` is a deliberate decision\n(suppressed, already covered by another task), `failed` is an error worth retrying. Only\n`pending` and `failed` tasks are picked up by the HubSpot poll.\n\n[NOTE]\n====\n`Activity.status` (default `\"sent\"`) and `Activity.direction` (default `\"outbound\"`) are plain\n`String` columns with no enum type behind them, as are the various `hubspotSyncStatus` fields\nand `Call.direction`. Treat their values as conventions, not constraints.\n====\n\n== Enums outside the data layer\n\nThese are not database enums, but they are shared vocabulary across deployment units.\n\n=== AgentModelIds\n\n`libs/morpheus-llm/morpheus_llm/utils/enums.py`. The only sanctioned source of Bedrock model\nidentifiers.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Member | Model id\n\n| `CLAUDE_OPUS_4_6` | `us.anthropic.claude-opus-4-6-v1`\n| `CLAUDE_SONNET_4` | `us.anthropic.claude-sonnet-4-20250514-v1:0`\n| `CLAUDE_HAIKU_4_5` | `us.anthropic.claude-haiku-4-5-20251001-v1:0`\n| `CLAUDE_HAIKU_3_5` | `us.anthropic.claude-3-5-haiku-20241022-v1:0`\n| `CLAUDE_HAIKU_3` | `us.anthropic.claude-3-haiku-20240307-v1:0`\n| `NOVA_MICRO` | `us.amazon.nova-micro-v1:0`\n| `NOVA_LITE` | `us.amazon.nova-lite-v1:0`\n| `NOVA_2_LITE` | `us.amazon.nova-2-lite-v1:0`\n|===\n\n[WARNING]\n====\nThe `us.` prefix selects a cross-region inference profile. Removing it produces a model id\nthat does not resolve in `{aws-region}`. Never strip it to \"clean up\" an identifier.\n====\n\n=== Call dispositions\n\n`libs/pyb-hubspot/pyb_hubspot/disposition_outcomes.py`.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Enum | Members\n\n| `Disposition`\n| `Qualified_Lead`, `Amazon_Seller`, `Sales_Handoff`, `Callback_Requested`, `Not_Qualified`,\n  `Not_Interested`, `Wrong_Number`, `Do_Not_Call`, `Not_a_Seller`, `Hangup_Early`, `AI_Issue`,\n  `Busy`, `AI_Screening`, `Gatekeeper`, `Voicemail_Detected`, `Undetermined`\n\n| `NextAction`\n| `None`, `Retarget_Later`, `Sales_Handoff`\n|===\n\n`Disposition` members are `Title_Case_With_Underscores` — the labels the dialer emits. HubSpot\ndoes not accept them directly; it wants an opaque outcome GUID, and the mapping lives in\n`FIVE9_DISPOSITION_TO_HUBSPOT_GUID` in `lambdas/morpheus_hubspot/utils/constants.py`. That\nmapping is environment-specific: the GUIDs differ between the production and preview portals,\nwhich is what `HUBSPOT_ENVIRONMENT` selects. See\nxref:business:call-logging.adoc[Call logging and dispositions].\n\n`NextAction` is what turns a disposition into work — `Retarget_Later` schedules re-engagement,\n`Sales_Handoff` creates a task for a human.\n\n=== Other\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Enum | Location | Members\n\n| `HubSpotApiEndpoint`\n| `libs/pyb-hubspot/pyb_hubspot/utils/enums/endpoints.py`\n| `COMMUNICATION_PREFERENCES_STATUSES_V2026_03` = `/communication-preferences/2026-03/statuses`\n\n| `CustomEventKind`\n| `libs/morpheus-common/morpheus_common/events/custom_events.py`\n| `agent_email`\n\n| `Marketplace`\n| `lambdas/enrichment_scorer/models/seller.py` (duplicated in the scraper)\n| `amazon`, `walmart`\n\n| `DataSource`\n| same\n| `smartscout`, `jungle_scout`, `walmart_scraper`\n|===\n\n[NOTE]\n====\n`Marketplace` and `DataSource` duplicate `SellerPlatform` and `SellerDatasource`, and\n`DataSource` carries two extra members with no data-layer equivalent. The duplication is\ndeliberate: a Lambda's Pydantic event models must be importable without the data-layer\nattached, so a queue payload can be validated by a function that does not touch the database.\nThe cost is that the two sets must be kept aligned by hand.\n====\n\n== Where enums are enforced\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Layer | Enforcement\n\n| PostgreSQL\n| A real `ENUM` type rejects an unknown value on write — for every enum mapped in the ORM with\n  a matching type name.\n\n| SQLAlchemy\n| Coerces to the Python member on read. `create_type=False` means it never creates or alters\n  the type.\n\n| Pydantic\n| Event models validate incoming payload values against the enum and reject the message before\n  a handler runs — see xref:events:publishing.adoc[Publishing events].\n\n| Bedrock structured output\n| `structured_output_model` constrains generation, so a model literally cannot return a value\n  outside the enum. See xref:libs:morpheus-llm.adoc[morpheus-llm].\n|===\n"},{"component":"morpheus","module":"data","path":"data:configs-table.adoc","title":"Runtime state in configs","description":"The key/value table that holds every watermark, cursor, and kill switch, and the exact keys in use.","url":"https://docx.payability.com/morpheus/data/configs-table.html","navOrder":62,"source":"= Runtime state in `configs`\n:description: The key/value table that holds every watermark, cursor, and kill switch, and the exact keys in use.\n\nMorpheus is stateless between invocations except for one table. `configs` is a key/value store\nthat holds the small amount of state a scheduled job needs to know where it left off.\n\nEverything in it is one of three things:\n\nWatermarks::\n\"I have processed every record updated before this timestamp.\" Advanced after a successful\nsync, so the next run only reads what changed.\n\nCursors::\n\"I stopped at this position.\" A row offset into a paginated API, or an index into an ordered\nlist of sales reps.\n\nKill switches::\n\"Do not run this.\" Set by hand to stop a job without a deploy.\n\nThere is no other runtime state. No DynamoDB table, no S3 marker object, no Parameter Store\nentry — a job's progress is a row in the same transaction as the work it did, which is what\nmakes the pipeline restartable.\n\n== The table\n\n`libs/morpheus-data-layer/morpheus_data_layer/models/configs.py`, table `configs`.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Column | Type | Notes\n\n| `id` | `UUID` | Primary key, `uuid.uuid4` default\n| `key` | `String` | **Globally unique** — not unique per subscription\n| `subscriptionId` | `UUID` | Tenant column; no ORM foreign key\n| `value` | `String` | Opaque; the reader parses it\n| `createdAt` | `DateTime(timezone=True)` | `server_default=now()`\n| `updatedAt` | `DateTime(timezone=True)` | `server_default=now()`, `onupdate`\n|===\n\nIndexes: `configs_key_idx`, `configs_subscription_id_idx`.\n\n[IMPORTANT]\n====\n`key` carries a **global** unique constraint, not a composite `(subscriptionId, key)` one.\nTwo subscriptions cannot hold the same key. Production runs a single tenant, so this has never\nbitten — but any key that must be per-tenant has to embed the subscription id in the key\nstring itself, which is exactly what the round-robin cursor does.\n====\n\n`value` is always a string. There is no JSON column and no type discriminator: the handler\nthat writes a key is the only thing that knows how to read it.\n\n== Reading and writing\n\nThrough `ConfigsRepository` — see xref:repositories.adoc[Repositories].\n\n[source,python]\n----\nfrom morpheus_data_layer.repositories import ConfigsRepository\n\n_configs_repo = ConfigsRepository()\n\nwith get_session() as session:\n    raw = _configs_repo.get_value(session, WATERMARK_KEY)          # <1>\n    watermark = parse(raw) if raw else DEFAULT_WATERMARK           # <2>\n\n    rows = fetch_since(watermark)\n    process(rows)\n\n    _configs_repo.set_value(session, WATERMARK_KEY,                # <3>\n                            serialise(new_watermark), subscription_id)\n----\n<1> Returns `None` when the key has never been written.\n<2> Every reader supplies a default. A missing key must never crash a handler — it is the\n    normal state on a first run.\n<3> `set_value()` upserts on `key`, updating only `value`.\n\nThe commit is the caller's. Because the watermark advance and the work share one transaction,\na failure mid-batch rolls both back and the next run reprocesses the window. Every consumer of\na watermark is therefore written to be idempotent — the engagement sync upserts on\n`(subscriptionId, externalId)`, the call sync upserts on `providerCallId`.\n\n[WARNING]\n====\nWriting the watermark in a *separate* transaction from the work would let the two diverge: an\nadvanced watermark plus a rolled-back batch silently loses records. Keep the `set_value()` call\ninside the same `with get_session()` block as the writes it describes.\n====\n\n== Keys in use\n\n[cols=\"2,1,2,2\",options=\"header\"]\n|===\n| Key | Kind | Written by | Value format\n\n| `enrichment.<marketplace>.disabled`\n| Kill switch\n| Set by hand\n| `\"true\"` (case-insensitive) disables; anything else or absent means active\n\n| `enrichment.<marketplace>.next_start_row`\n| Cursor\n| `enrichment_scraper/services/config_state.py`\n| Integer as a decimal string\n\n| `bigquery_engagement_sync_watermark`\n| Watermark\n| `morpheus_hubspot/events/scheduled/bigquery_engagement_sync.py`\n| `%Y-%m-%dT%H:%M:%S%z`\n\n| `five9_call_sync_watermark`\n| Watermark\n| `morpheus_hubspot/events/scheduled/five9_call_sync.py`\n| ISO-8601 with offset\n\n| `bq_contact_sync_watermark`\n| Watermark\n| `enrichment_scraper/events/bigquery/contact_sync.py`\n| ISO-8601 from `datetime.isoformat()`, microsecond precision\n\n| `dialer_follow_up_rep_rr:<subscriptionId>`\n| Cursor\n| `morpheus_data_layer/repositories/sales_rep_round_robin.py`\n| Non-negative integer as a string\n|===\n\n=== enrichment.<marketplace>.disabled\n\nRead by `ConfigStateService.is_disabled()` and checked by the Amazon discovery handler before\nit calls SmartScout. Nothing in the codebase writes it — it exists to be set manually:\n\n[source,sql]\n----\nINSERT INTO configs (id, key, value, \"subscriptionId\")\nVALUES (gen_random_uuid(), 'enrichment.amazon.disabled', 'true', '{subscription-id}')\nON CONFLICT (key) DO UPDATE SET value = 'true';\n----\n\nThis is the fastest way to stop spending SmartScout quota. It takes effect on the next\nscheduled run with no deploy and no schedule change, and re-enabling is the same statement\nwith `'false'`. See xref:ROOT:operations.adoc[Operations and runbooks].\n\n=== enrichment.<marketplace>.next_start_row\n\nThe SmartScout pagination offset. `ConfigStateService.get_next_start_row()` reads it,\n`save_next_start_row()` advances it after a successful page. Discovery walks the seller\ncatalogue linearly rather than re-querying from zero, so each run covers new ground.\n\nSetting it back to `\"0\"` restarts the walk from the beginning — safe, because every downstream\nwrite is an upsert on `sellerId`, so a replay refreshes rows instead of duplicating them.\n\nBoth scraper keys are templated on marketplace, so `enrichment.walmart.next_start_row` is\nvalid. Only `amazon` is used in production; the Walmart discovery handler is a stub.\n\n=== The three watermarks\n\nAll three follow the same shape — read, default if absent, query \"updated since\", advance —\nbut differ in their defaults and precision.\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Key | Default when absent | Source it paginates\n\n| `bigquery_engagement_sync_watermark`\n| `2026-05-15T00:00:00Z`\n| `hubspot4.engagements`, filtered on `lastupdated`\n\n| `five9_call_sync_watermark`\n| `2026-01-01T00:00:00+00:00`\n| Five9 call export\n\n| `bq_contact_sync_watermark`\n| `2026-01-01T00:00:00Z`\n| `hubspot4.contacts`, filtered on `updatedat`\n|===\n\nThe defaults are backfill floors, chosen so a first run in an empty environment imports a\nuseful history rather than everything ever recorded. They are not \"the beginning of time\"\non purpose: `hubspot4` holds years of engagements, and an unbounded first query would time out.\n\n[NOTE]\n====\nBigQuery is a periodic export, so a row's `lastupdated` can be *older* than the moment it\nappears in the warehouse. A watermark advanced to exactly the last-seen timestamp can skip\nrecords that land late. Both BigQuery syncs deduplicate on the target key\n(`(subscriptionId, externalId)` for activities, contact identity for contacts), so an\noverlapping window is harmless — which is what makes it safe to rewind a watermark by a few\nhours when you suspect a gap.\n====\n\nRewinding is the standard repair for a missed window:\n\n[source,sql]\n----\nUPDATE configs\nSET value = '2026-08-01T00:00:00+0000'\nWHERE key = 'bigquery_engagement_sync_watermark';\n----\n\nThe next scheduled run reprocesses everything after that instant and upserts over what is\nalready there.\n\n=== dialer_follow_up_rep_rr:<subscriptionId>\n\nThe only per-tenant key, and the only one that embeds its subscription id in the key string —\na workaround for the global unique constraint on `key`.\n\nThe value is an index into the ordered list of active sales reps returned by\n`list_active_for_subscription()` (ordered `id ASC`). `reserve_sales_rep_round_robin_batch()`\ntakes a transaction-level advisory lock, reads the index, hands out `count` reps from that\nposition, and writes the index back advanced by `count`.\n\nThe lock is what makes concurrent handlers safe: four re-engagement schedules run on the same\n`rate(6 hours)` cadence and would otherwise read the same cursor and assign the same rep to\nevery batch. Details in xref:business:ownership.adoc[Ownership and round-robin].\n\nDeleting the key resets assignment to the first rep. Adding or deactivating a rep shifts the\nordering under the cursor, which skews one cycle's distribution and then self-corrects.\n\n== Inspecting state\n\n[source,sql]\n----\nSELECT key, value, \"updatedAt\"\nFROM configs\nWHERE \"subscriptionId\" = '{subscription-id}'\nORDER BY \"updatedAt\" DESC;\n----\n\n`updatedAt` is the useful column in an incident: a watermark whose `updatedAt` is hours stale\nmeans the schedule that owns it has not completed successfully, and that is usually a faster\nsignal than reading logs.\n\n== Adding a key\n\n. Define the key as a module-level constant next to the handler that owns it — never inline a\n  string literal at the call site.\n. Choose a namespaced name. Dots for scoping (`enrichment.amazon.next_start_row`), a colon for\n  a tenant suffix.\n. Give the reader a sensible default for the absent case. It must not raise.\n. Serialise deterministically — a fixed `strftime` format or `isoformat()`, never `str()` on a\n  datetime.\n. Write it in the same transaction as the work it describes.\n. Add a row to the table on this page.\n"},{"component":"morpheus","module":"data","path":"data:warehouse.adoc","title":"BigQuery warehouse","description":"The datasets Morpheus reads, how they get there, the deduplication pattern, and how credentials are supplied.","url":"https://docx.payability.com/morpheus/data/warehouse.html","navOrder":63,"source":"= BigQuery warehouse\n:description: The datasets Morpheus reads, how they get there, the deduplication pattern, and how credentials are supplied.\n\nBigQuery is the analytical plane. Every re-engagement segment, every priority score, and every\n\"who should we call today\" decision is a SQL query against it — not against PostgreSQL.\n\nThe reason is join shape. Deciding that a contact clicked an email three days ago, has an open\ndeal in a stalled stage, was last called by a rep who has since gone quiet, and has no active\ntask, means joining HubSpot engagements, deals, contacts, and application tables across\nmillions of rows. PostgreSQL holds the operational truth for a *single* record; BigQuery is\nwhere a population is scanned.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Dataset | Origin | Contents\n\n| `{bq-dataset-hubspot}`\n| Stitch export of the HubSpot portal `{hubspot-portal}`\n| `contacts`, `engagements`, `deals`, plus portal-managed views\n\n| `{bq-dataset-app}`\n| Mirror of the operational PostgreSQL database\n| `contacts`, `CampaignTarget`, `Call`, `email_campaign_targets`, and the Morpheus-authored views\n|===\n\nBoth live in project `{bq-project}`.\n\n[IMPORTANT]\n====\nNothing in Morpheus *writes* to BigQuery. Both datasets are replicas maintained outside this\nrepository — Stitch for the HubSpot side, a database replication job for the application side.\nMorpheus only issues `SELECT`. The one exception is `vm_contacts_summary`, a table that is\nrebuilt by running its `CREATE OR REPLACE TABLE` statement by hand.\n====\n\n[#lag]\n== Export lag\n\nThe warehouse is a periodic export, so it is always behind reality by some interval. This is\nnot an edge case to be engineered away — it is a property every consumer is written to\ntolerate.\n\nConcrete consequences:\n\n* A contact who booked a meeting five minutes ago can still appear in a re-engagement segment,\n  because the deal stage change has not exported yet.\n* A watermark advanced to exactly the newest `lastupdated` seen can skip records that land\n  late.\n* A view that says a contact has no active task may be describing a task created after the\n  export ran.\n\nThe mitigations are consistent across handlers:\n\nSuppression is re-checked in PostgreSQL::\nA segment produces a *candidate* list. Before a handler creates work it calls\n`has_active_task_for_contact()` against the live operational database, which sees the task\nBigQuery does not yet know about. The warehouse proposes; PostgreSQL disposes. See\nxref:business:re-engagement.adoc[Re-engagement segments].\n\nOverlapping windows are safe::\nEvery watermark consumer upserts on a natural key, so reprocessing a window updates rows\ninstead of duplicating them. That makes it safe to rewind a watermark — see\nxref:configs-table.adoc[Runtime state in `configs`].\n\n[#dedup]\n== The deduplication pattern\n\nThe Stitch export is append-only: updating a HubSpot contact appends a new row rather than\nreplacing the old one. Every raw table therefore holds a full version history, and a naive\n`SELECT` returns a contact once per edit it has ever received.\n\nEvery query against `{bq-dataset-hubspot}` opens by collapsing to the newest version:\n\n[source,sql]\n----\nSELECT * FROM (\n  SELECT\n    contacts.*,\n    ROW_NUMBER() OVER (PARTITION BY contacts.id ORDER BY contacts.updatedat DESC) AS rn\n  FROM `{bq-project}.{bq-dataset-hubspot}.contacts` AS contacts\n)\nWHERE rn = 1\n----\n\nThe same shape appears with `PARTITION BY` on the association key and `ORDER BY` on a\ntimestamp wherever \"the latest one\" is needed — the last call engagement per contact, the most\nrecent deal per contact. `vm_contacts_summary` uses it twice: once to collapse contact\nversions, once to pick each contact's most recent `CALL` engagement.\n\n[WARNING]\n====\nOmitting the `ROW_NUMBER()` filter does not fail — it silently multiplies rows. A segment that\nsuddenly returns three times as many contacts as expected, all of them plausible, is almost\nalways a missing `rn = 1`.\n====\n\nEngagements need a second unnesting step, because `associations.contactids` is a repeated\nfield — one engagement can touch several contacts:\n\n[source,sql]\n----\nFROM `{bq-project}.{bq-dataset-hubspot}.engagements` AS e,\n  UNNEST(e.associations.contactids) AS contact_id\nWHERE e.engagement.type = 'CALL'\n----\n\n== Access from Lambda\n\nThe `morpheus-bigquery` layer exposes exactly one function:\n\n[source,python]\n----\nfrom morpheus_bigquery import create_bigquery_client\n\nclient = create_bigquery_client(credentials_json=secrets.bigquery_credentials)\n----\n\nIt parses a service-account JSON string, builds\n`service_account.Credentials.from_service_account_info(...)`, and returns a\n`bigquery.Client` whose project is taken from the credentials rather than from configuration.\n\n[NOTE]\n====\nThe library never reads an environment variable and never touches Secrets Manager. The\ncredential string is passed in by the Lambda handler, which fetched it from the\n`BIGQUERY_CREDENTIALS` key of `{shared-secret}`. That is the same rule the MCP tool factories\nin `morpheus-llm` follow, and it is what keeps a library testable without AWS. See\nxref:ROOT:configuration.adoc[Configuration and secrets].\n====\n\nThere are no repository classes, query builders, or ORM mappings in `morpheus-bigquery` — the\nlayer is a credential adapter and nothing more. SQL lives either in a `BQ_*_QUERY` constant\nnext to the handler that runs it, or in a view under `bigquery/`. See\nxref:libs:morpheus-bigquery.adoc[morpheus-bigquery].\n\n=== Which functions get the layer\n\n`MorpheusBigQueryLayer` is attached to three of the five functions:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Function | Reads\n\n| xref:lambdas:enrichment-scraper.adoc[enrichment-scraper]\n| `{bq-dataset-hubspot}.contacts` — mirrors HubSpot contacts into PostgreSQL\n\n| xref:lambdas:morpheus-hubspot.adoc[morpheus-hubspot]\n| `{bq-dataset-hubspot}.engagements` and four re-engagement views\n\n| xref:lambdas:phone-agent.adoc[phone-agent]\n| `v_amazon_seller_contact_engagement_summary`\n|===\n\n`enrichment-scorer` and `email-agent` have no warehouse access at all.\n\n== Parameterised queries\n\nInline queries bind parameters rather than interpolating them:\n\n[source,python]\n----\njob_config = bigquery.QueryJobConfig(\n    query_parameters=[\n        bigquery.ScalarQueryParameter(\"watermark\", \"TIMESTAMP\", watermark),\n        bigquery.ScalarQueryParameter(\"row_limit\", \"INT64\", row_limit),\n    ]\n)\nrows = client.query(BQ_ENGAGEMENT_QUERY, job_config=job_config).result()\n----\n\nBeyond the injection argument, parameter binding lets BigQuery reuse a cached query plan\nacross runs, and it keeps the SQL constant readable as SQL.\n\n`@row_limit` appears on both incremental syncs. It bounds a single run's cost and keeps the\njob inside the function's timeout: a very wide watermark window yields a truncated batch and\nan advanced-but-not-current watermark, and the next scheduled run continues from there.\n\n== Cost\n\nQueries are billed by bytes scanned, and the raw tables are wide and version-heavy. Two habits\nkeep it in hand:\n\n* Select named columns. `SELECT *` against `hubspot4.contacts` scans several hundred property\n  columns, most of which no handler uses.\n* Filter on the partitioning/watermark column *before* joining, so the deduplication window\n  only spans changed rows.\n\nThe re-engagement views are the expensive objects, because each collapses full version history\nacross contacts, engagements, and deals. They run on a `rate(6 hours)` cadence — see\nxref:infrastructure:schedules.adoc[EventBridge schedules] — which is a deliberate ceiling on\nspend as much as a freshness target.\n\n== Objects\n\nEvery view, its defining file, its columns, and its consumer:\nxref:bigquery-views.adoc[BigQuery views].\n"},{"component":"morpheus","module":"data","path":"data:bigquery-views.adoc","title":"BigQuery views","description":"Every view and materialisation in bigquery/, its output columns, and the handler that consumes it.","url":"https://docx.payability.com/morpheus/data/bigquery-views.html","navOrder":64,"source":"= BigQuery views\n:description: Every view and materialisation in bigquery/, its output columns, and the handler that consumes it.\n\nThe `bigquery/` directory at the repository root holds eleven `.sql` files. Each is a complete\n`CREATE OR REPLACE VIEW` (or `TABLE`) statement, checked in so the definition is reviewable,\ndiffable, and deployable by hand.\n\n[IMPORTANT]\n====\nThese files are **not deployed by CDK or SAM**. Nothing in the pipeline creates or refreshes\nthem. A change to a `.sql` file takes effect only when someone runs it against BigQuery:\n\n[source,bash,subs=attributes+]\n----\nbq query --project_id={bq-project} --use_legacy_sql=false < bigquery/v_lost_deal_reeng.sql\n----\n\nA handler reading a view that has drifted from its checked-in definition is the failure mode\nto watch for — the code review passed, the query did not change. Re-run the file after\nmerging any change to it.\n====\n\n== The five consumed by handlers\n\nThese four views plus one summary drive live behaviour. The membership and priority rules\ninside them are documented per segment in\nxref:business:re-engagement.adoc[Re-engagement segments]; this page is the structural\nreference.\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| View | Consumer | Segment\n\n| `v_amazon_seller_contact_engagement_summary`\n| xref:lambdas:phone-agent.adoc[phone-agent] — `Phone.CampaignTargetReEngagement`\n| Amazon seller campaign\n\n| `v_email_clicked_re_engagement_summary`\n| xref:lambdas:morpheus-hubspot.adoc[morpheus-hubspot] — `Hubspot.EmailClickReEngagement`\n| Email click\n\n| `v_supplier_account_created_reeng`\n| morpheus-hubspot — `Hubspot.SupplierAccountReEngagement`\n| Supplier account created\n\n| `v_unattended_meeting_reeng`\n| morpheus-hubspot — `Hubspot.UnattendedMeetingReEngagement`\n| Unattended meeting\n\n| `v_lost_deal_reeng`\n| morpheus-hubspot — `Hubspot.LostDealReEngagement`\n| Lost or stalled deal\n|===\n\nAll five live in `{bq-project}.{bq-dataset-app}`.\n\n=== v_amazon_seller_contact_engagement_summary\n\nJoins `outreach_app.CampaignTarget` and `outreach_app.Call` against HubSpot contact and deal\nstate to find campaign targets whose call disposition warrants another attempt.\n\n[source]\n----\nid                              disposition\nhubspotcontactid                contactid\nlifecycle_stage\ndeal_id                         deal_status         deal_stage      deal_owner\nlast_call_engagement_timestamp  engagement_owner_id engagement_disposition\nlast_active_task_id             task_created_date   task_due_date   task_status\noutreach_last_call_timestamp    priority\n----\n\n`priority` is emitted by the view, but phone-agent recomputes its own in Python rather than\ntrusting it — the only one of the five segments that does. The `last_active_task_id` and\n`task_status` columns are the warehouse's view of suppression; the handler still re-checks\nPostgreSQL before creating a task.\n\n=== v_email_clicked_re_engagement_summary\n\nContacts who clicked a link in a campaign email, joined from\n`outreach_app.email_campaign_targets` to HubSpot contact, deal, and engagement state.\n\n[source]\n----\nid                              email               contactid\nhubspot_contact_id              lifecycle_stage\ndeal_id                         deal_status         deal_stage      deal_owner\nlast_call_engagement_timestamp  engagement_owner_id engagement_disposition\nlast_active_task_id             task_created_date   task_due_date   task_status\nlast_clicked_at                 priority\n----\n\nStructurally near-identical to the Amazon seller view — the same deal, engagement, and task\ncolumns — differing in its entry signal (`last_clicked_at` instead of a call disposition) and\nin carrying `email` for identity resolution.\n\n=== v_supplier_account_created_reeng\n\nDeals where a supplier account was created but the funnel stalled.\n\n[source]\n----\ndealid              contact_id          first_name      last_name\nemail               phone               hubspot_owner_id\ndeal_stage          deal_status         pipeline\ncreated_date        deal_amount\nlast_call_date      total_calls         disposition_id  disposition_label\ndays_since_last_call                    priority_score\n----\n\nThe three deal-centric views (`supplier_account_created`, `unattended_meeting`,\n`lost_deal`) share a shape the contact-centric ones do not: they carry the contact's\n`first_name`, `last_name`, `email`, and `phone` inline, so a handler can create a HubSpot task\nwith a readable subject line without a second lookup.\n\n`priority_score` here is a raw numeric score, converted to a task priority band in Python —\nsee xref:business:re-engagement.adoc#score-to-priority-conversion[score-to-priority conversion].\n\n=== v_unattended_meeting_reeng\n\nDeals with a booked meeting the prospect did not attend. The widest of the five.\n\n[source]\n----\ndealid              contact_id          first_name      last_name\nemail               phone               hubspot_owner_id    bdr_rep\ndeal_stage          deal_status         pipeline\ncreated_date        deal_amount\nlast_meeting_date   meeting_outcome     total_meetings\nlast_activity_date  total_activities    days_since_last_activity\npriority_score\n----\n\n`bdr_rep` is unique to this view: a no-show is routed back to the BDR who booked the meeting\nrather than through the round-robin, because that person already has the context. See\nxref:business:ownership.adoc[Ownership and round-robin].\n\n`meeting_outcome` distinguishes a genuine no-show from a rescheduled or cancelled meeting.\n\n=== v_lost_deal_reeng\n\nClosed-lost deals worth revisiting.\n\n[source]\n----\ncontact_id          first_name          last_name       email       phone\ncontact_owner_id\ndealid              deal_stage          closed_lost_reason\ndeal_last_updated   last_modified_date  hubspot_owner_id\nlast_call_date      total_calls         disposition_id  disposition_label\ndays_since_last_call                    priority_score\n----\n\n`closed_lost_reason` is what makes the segment selective — a deal lost on price or timing is\nworth another call, one lost as \"not a fit\" is not. It carries both `contact_owner_id` and\n`hubspot_owner_id` because a lost deal's owner and its contact's owner frequently differ after\na reassignment.\n\n== Reporting views\n\nDefined, correct, and consumed by nothing in the pipeline. They exist for dashboards and\nmanual analysis.\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Object | Purpose\n\n| `v_call_summary`\n| Per-call reporting. Reads `hubspot4.v_engagements_with_owner`, a portal-managed view that is\n  *not* defined in this repository.\n\n| `v_email_campaign_summary`\n| Per-campaign email totals — sends, opens, clicks, bounces.\n\n| `v_email_campaign_daily_summary`\n| The same metrics bucketed by day.\n\n| `v_email_campaign_daily_summary_unpivoted`\n| Long-format variant of the daily summary, one row per metric, for charting tools that want\n  tidy data.\n\n| `v_email_campaign_sankey`\n| Stage-to-stage transition counts for a funnel diagram.\n\n| `vm_contacts_summary`\n| A **table**, not a view — `CREATE OR REPLACE TABLE` in `{bq-dataset-hubspot}`.\n|===\n\n[NOTE]\n====\n`vm_contacts_summary` is materialised rather than virtual: it collapses HubSpot contact\nversion history and joins each contact's most recent `CALL` engagement, which is expensive\nenough that dashboards querying it repeatedly would be wasteful. The `vm_` prefix marks the\ndistinction.\n\nBeing a table, it is only as fresh as its last manual rebuild. Nothing schedules it. Anything\nreading it should treat its contents as a snapshot of unknown age.\n====\n\n`v_call_summary` depends on `hubspot4.v_engagements_with_owner`, which is managed in the\nBigQuery console. A change there can break the view with no corresponding commit here.\n\n== Conventions\n\nDeduplicate first::\nEvery view over `{bq-dataset-hubspot}` opens with the\n`ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedat DESC) … WHERE rn = 1` collapse. The\nexport is append-only. See xref:warehouse.adoc#dedup[the deduplication pattern].\n\nEmit a numeric score, not a band::\nViews produce `priority` or `priority_score` as a number. Converting it into a task priority\nis Python's job, so the thresholds can be tuned without re-running DDL.\n\nReturn snake_case::\nView columns are snake_case (`last_call_engagement_timestamp`), unlike the camelCase of the\noperational schema. They are read positionally or by name into dictionaries, never mapped to\nORM models.\n\nSuppression columns are advisory::\n`last_active_task_id`, `task_status`, and `task_due_date` exist so a view can pre-filter, but\nthey are as stale as the export. Every handler re-checks\n`has_active_task_for_contact()` against PostgreSQL before writing.\n\n== Changing a view\n\n. Edit the `.sql` file under `bigquery/`.\n. Run it against `{bq-project}` — nothing deploys it for you.\n. Verify the column list still matches what the handler reads; a renamed column produces a\n  `KeyError` at runtime, not at deploy time.\n. If the membership or priority rule changed, update\n  xref:business:re-engagement.adoc[Re-engagement segments] and\n  xref:business:call-lists.adoc[Daily call lists] in the same pull request.\n"},{"component":"morpheus","module":"data","path":"data:prisma-schema.adoc","title":"Prisma schema reference","description":"The authoritative PostgreSQL schema, which parts Morpheus maps, and where the SQLAlchemy models have drifted from it.","url":"https://docx.payability.com/morpheus/data/prisma-schema.html","navOrder":65,"source":"= Prisma schema reference\n:description: The authoritative PostgreSQL schema, which parts Morpheus maps, and where the SQLAlchemy models have drifted from it.\n\nThe PostgreSQL schema is defined by the Payability web application's Prisma schema. That file\nis the authority: it owns the tables, the enum types, the constraints, and the migrations.\n\nThis repository contains **no migrations**. `morpheus_data_layer` mirrors a subset of the\nschema so Python can read and write it. Every model docstring says the same thing —\n\"Read/write only — schema modifications are not permitted via this layer.\"\n\nA copy is checked in at `docs/modules/data/examples/schema.prisma` and reproduced in full at\nthe xref:#full-schema[bottom of this page].\n\n[IMPORTANT]\n====\nThe checked-in copy is a snapshot for reference. The live schema lives in the web application\nrepository and can move without a commit here. When something disagrees, the web application's\nschema wins — and this copy needs refreshing.\n====\n\n== Adding a column, end to end\n\n. Add the field to the web application's Prisma schema and migrate it there.\n. Add the matching `mapped_column` to the model in `morpheus_data_layer/models/`, using the\n  exact camelCase column name.\n. If it is an enum column, add the `StrEnum` to `morpheus_data_layer/enums.py` and build the\n  PostgreSQL type with `create_type=False` — see xref:enums.adoc[Enumerations].\n. Add it to `update_columns` anywhere it should participate in an upsert.\n. Refresh the copy under `examples/` and update xref:orm-models.adoc[ORM models].\n\nDoing step 2 before step 1 produces a runtime error on the first query touching the column,\nnot a startup failure — SQLAlchemy does not validate the mapping against the database.\n\n== Coverage\n\nPrisma declares 28 models. Morpheus maps 13.\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Prisma model | Table | Mapped in `morpheus_data_layer`\n\n| `Seller` | `sellers` | Yes\n| `Contact` | `contacts` | Yes\n| `Activity` | `Activity` | Yes\n| `Call` | `Call` | Yes\n| `CampaignTarget` | `CampaignTarget` | Yes\n| `Configs` | `configs` | Yes\n| `Emails` | `emails` | Yes — as `Email`\n| `EmailCampaignTarget` | `email_campaign_targets` | Yes\n| `EmailVariation` | `email_variations` | Yes\n| `FollowUpTask` | `follow_up_tasks` | Yes\n| `SalesRep` | `sales_reps` | Yes\n| `TargetEmails` | `target_emails` | Yes\n| `TargetPhoneNumbers` | `target_phone_numbers` | Yes — as `TargetPhoneNumber`\n| `Subscription` | `subscriptions` | No\n| `SubscriptionUser` | — | No\n| `Campaign` | — | No\n| `Profile` | — | No\n| `AiAgent` | `ai_agents` | No\n| `AiAgentVersion` | `ai_agent_versions` | No\n| `AgentTestCase` | `agent_test_cases` | No\n| `CallAiAgentAnalysisResult` | — | No\n| `WebhookEvent` | — | No\n| `PhoneNumbers` | — | No\n| `BlockedPhoneNumbers` | `blocked_phone_numbers` | No\n| `Domain` | `domains` | No\n| `EmailSendingDomain` | `email_sending_domains` | No\n| `SequenceTemplate` | `sequence_templates` | No\n| `SequenceTemplateStep` | `sequence_template_steps` | No\n| `ContactSequence` | `contact_sequences` | No\n| `ContactSequenceStep` | `contact_sequence_steps` | No\n|===\n\nThe unmapped half is the web application's own domain — campaign definitions, sequence\ntemplates, AI agent configuration, user profiles, domain warm-up. Morpheus consumes the\n*results* of those systems through HubSpot and BigQuery rather than reading their tables.\n\nNote the naming inconsistency the ORM inherits: some models declare `@@map` to a snake_case\nplural table, others do not and keep a PascalCase singular name. `Activity`, `Call`, and\n`CampaignTarget` are quoted identifiers in SQL for that reason.\n\n== Relations\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Relation | Meaning\n\n| `Subscription` 1—* everything\n| The tenant boundary. Every domain table carries `subscriptionId`.\n\n| `Seller` 1—* `Contact`\n| A business and its people. `Seller` maps to a HubSpot **Company** via `hubspotCompanyId`;\n  contact ids belong on `Contact`.\n\n| `Contact` 1—* `CampaignTarget`, `EmailCampaignTarget`, `TargetPhoneNumbers`, `TargetEmails`, `Activity`, `FollowUpTask`, `Emails`, `ContactSequence`\n| The hub of the model. Almost everything hangs off a person.\n\n| `CampaignTarget` 1—* `Call`, `FollowUpTask`\n| One phone number in a campaign, and every attempt against it.\n\n| `Call` *—1 `SalesRep`, *—1 `TargetPhoneNumbers`\n| Who dialled, and the registry entry for the number dialled.\n\n| `FollowUpTask` *—1 `SalesRep`, *—1 `Contact`\n| A commitment and its owner. `CampaignTarget` and `ContactSequence` are optional.\n\n| `Activity` unique `(subscriptionId, externalId)`\n| The idempotency key for the engagement sync.\n|===\n\n[NOTE]\n====\n`ForeignKey(\"subscriptions.id\")` is declared in Prisma but deliberately **omitted** from every\nSQLAlchemy model. The constraint is enforced by the database; declaring it in the ORM would\nmake this layer claim ownership of a table it does not manage. Other foreign keys — such as\n`Contact.sellerId` → `sellers.id` — *are* declared.\n====\n\n[#drift]\n== Known drift\n\nReal differences between Prisma and the SQLAlchemy models. None currently break at runtime,\nbut each is a trap.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Area | Prisma | SQLAlchemy\n\n| `SellerLifeCycleStage`\n| 6 values: `new`, `enriched`, `qualified`, `opportunity`, `closed_won`, `closed_lost`\n| 11 values, a longer ladder including `raw_lead`, `in_sequence`, `bdr_handoff`,\n  `demo_scheduled`, `customer`, `disqualified`\n\n| `SellerPlatform`\n| `amazon`, `walmart`, `shopify`\n| `amazon`, `walmart` — no `shopify`\n\n| `Contact` uniqueness\n| Composite: `(subscriptionId, email)`, `(subscriptionId, phone)`,\n  `(subscriptionId, hubspotContactId)`, `(subscriptionId, supplierKey)`\n| Global nullable-unique on `email`, `phone`, `hubspotContactId`\n\n| `Contact` fields\n| Has `leadScore`, `customFields`; `source` is a `ContactSource` enum\n| No `leadScore` or `customFields`; `source` is a plain `String`\n\n| `EmailCampaignTarget.emailId`\n| Required foreign key to `TargetEmails`\n| Nullable\n\n| `EmailVariation`\n| Has `spamScore`, `markedAsSpam`\n| Neither mapped\n\n| `TargetEmails`\n| Has `newsletterGapDays`, `newsletterLastSentAt`, `@@unique([subscriptionId, email])`\n| Neither field mapped\n\n| `TargetPhoneNumbers`\n| Has `updatedAt`\n| Not mapped\n\n| `FollowUpTask`\n| Has `contactSequenceId`, `inngestRunId`\n| Neither mapped\n|===\n\nTwo of these deserve attention:\n\nThe lifecycle enum mismatch is the significant one::\nIf the Prisma set is what the database type actually holds, then writing `raw_lead` or\n`bdr_handoff` from Python would be rejected by PostgreSQL. Either the checked-in Prisma copy\nis stale or those stages are unreachable in practice. Verify against the live type before\nrelying on any stage beyond `enriched` — see xref:enums.adoc[Enumerations].\n\nContact uniqueness is composite upstream, global here::\nPrisma scopes uniqueness to the subscription; the ORM does not. On a single tenant the two are\nequivalent, which is why nothing has broken. Onboarding a second subscription would make the\nORM's global constraint reject a legitimate row — two tenants cannot hold the same email\naddress. `upsert_for_seller()` already works around collisions by stripping conflicting values\n(see xref:repositories.adoc[Repositories]); that workaround becomes load-bearing the moment a\nsecond tenant exists.\n\nEverything else in the table is a field Morpheus simply does not read. Unmapped columns are\nharmless — SQLAlchemy ignores columns it does not declare — but they will not appear on a\nmodel instance, and an upsert that omits them leaves whatever the web application wrote intact.\n\n== Enums declared only in Prisma\n\n`CampaignStatus`, `AiAgentType`, `CampaignType`, `AiAgentLlmModelType`, `SubscriptionUserRole`,\n`SequenceType`, `SequenceStatus`, `SequenceStepChannel`, `DomainWarmupStatus`.\n\nAll belong to unmapped tables. There is no reason to mirror them until a handler reads one.\n\n[#full-schema]\n== Full schema\n\n[source,prisma]\n----\ninclude::example$schema.prisma[]\n----\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:index.adoc","title":"Infrastructure","description":"The two independent infrastructure-as-code stacks, what each owns, and where the boundary sits.","url":"https://docx.payability.com/morpheus/infrastructure/index.html","navOrder":66,"source":"= Infrastructure\n:description: The two independent infrastructure-as-code stacks, what each owns, and where the boundary sits.\n\nEvery AWS resource Morpheus uses is declared in one of two places. There is no console-created\ninfrastructure and no Terraform.\n\n[cols=\"1,1,1,2\",options=\"header\"]\n|===\n| Stack | Tool | Language | Owns\n\n| `{cdk-stack}`\n| AWS CDK\n| TypeScript\n| The xref:agentcore:index.adoc[AgentCore runtime] — container image, Bedrock AgentCore\n  runtime and memory, its IAM role, its log group\n\n| `{sam-stack}`\n| AWS SAM\n| YAML\n| Five Lambda functions, five layers, three queues and their dead-letter queues, every\n  schedule, the shared secret, one alarm\n|===\n\nThey deploy independently, share no CloudFormation exports, and reference each other through\nnothing at all. The coupling between the two halves of the platform is at the *data* level —\nthe same PostgreSQL database, the same HubSpot portal — not at the infrastructure level.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Page | Contents\n\n| xref:cdk-stack.adoc[CDK stack (AgentCoreStack)]\n| Every construct, the image asset, memory strategies, outputs\n\n| xref:sam-template.adoc[SAM template]\n| Globals, parameters, all five functions, outputs\n\n| xref:queues.adoc[Queues and dead-letter queues]\n| FIFO configuration, visibility timeouts, redrive, partial batch failure\n\n| xref:schedules.adoc[EventBridge schedules]\n| Every rule, its cron or rate expression, and its `Input` payload\n\n| xref:layers.adoc[Lambda layers]\n| The five layers, layer order, and how they are built\n\n| xref:secrets.adoc[Secrets Manager]\n| The two secrets, every key, and how each unit loads them\n\n| xref:iam.adoc[IAM]\n| Runtime role policies, SAM-generated roles, the permission model\n\n| xref:observability.adoc[Observability]\n| Sentry, CloudWatch, X-Ray, the Bedrock token alarm\n|===\n\n== Deploying\n\n[source,bash]\n----\nmake deploy-cdk       # cd cdk && npx cdk bootstrap && npm run cdk:deploy\nmake deploy-lambdas   # cd lambdas && make deploy\n----\n\n`make deploy-lambdas` regenerates every function's `requirements.txt` with\n`uv export --no-dev --no-hashes` before running `sam build --no-cached && sam deploy`. That\nregeneration step is why a dependency change made in a `pyproject.toml` reaches Lambda without\nanyone editing a `requirements.txt` by hand.\n\nSingle functions can be deployed alone — `make deploy-scraper`, `deploy-scorer`,\n`deploy-hubspot`, `deploy-email-agent`, `deploy-phone-agent` — each of which syncs only that\nfunction's requirements and builds only that function. Full details in\nxref:ROOT:deployment.adoc[Deployment].\n\n[IMPORTANT]\n====\nThere is no deployment pipeline. The only GitHub Actions workflow is\n`.github/workflows/security-review.yml`, which runs an AI security and architecture review on\npull requests. It does not build, test, or deploy anything. Every deploy is a developer\nrunning `make` against `{aws-account}`.\n====\n\n== Architecture split\n\nThe two stacks target different CPU architectures, and neither is negotiable independently.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Unit | Architecture | Why\n\n| AgentCore container\n| `arm64`\n| `DockerImageAsset` defaults to `Platform.LINUX_ARM64`. The `LINUX_AMD64` alternative is\n  present but commented out in `cdk/bin/cdk.ts`.\n\n| Lambda functions\n| `x86_64`\n| Set in the SAM `Globals` block. Layers declare `CompatibleArchitectures: [x86_64]` to match.\n|===\n\nA layer built for one architecture will not load on a function running the other, so changing\nthe SAM architecture means changing all five layer declarations in the same commit.\n\n== What is not in code\n\nThree things the stacks reference but do not create:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Resource | Where it comes from\n\n| The secret *value* of `{shared-secret}`\n| SAM creates an empty secret; the JSON is populated by hand. See\n  xref:secrets.adoc[Secrets Manager].\n\n| The SNS topic `security-operational-issues`\n| Referenced by `BedrockTokenAlarm` as an alarm action, created outside this repository.\n\n| The BigQuery views under `bigquery/`\n| Applied manually with `bq query`. See xref:data:bigquery-views.adoc[BigQuery views].\n|===\n\nEach is a place where a fresh environment will deploy cleanly and then misbehave at runtime.\nThe bootstrap sequence for a new account is in xref:ROOT:deployment.adoc[Deployment].\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:cdk-stack.adoc","title":"CDK stack (AgentCoreStack)","description":"Every construct in the single CDK stack — image asset, AgentCore memory and runtime, roles, log group, outputs.","url":"https://docx.payability.com/morpheus/infrastructure/cdk-stack.html","navOrder":67,"source":"= CDK stack (AgentCoreStack)\n:description: Every construct in the single CDK stack — image asset, AgentCore memory and runtime, roles, log group, outputs.\n\n`{cdk-stack}` is the only CDK stack, defined in `cdk/lib/stacks/agentcore-stack.ts` and\ninstantiated in `cdk/bin/cdk.ts`. It deploys the xref:agentcore:index.adoc[AgentCore runtime]\nand nothing else.\n\n[IMPORTANT]\n====\nNever split this into a second stack, and never import an external role with\n`iam.Role.fromRoleArn()`. CDK owns every IAM identity it uses. Both rules are in `AGENTS.md`\nand both exist because the runtime's permissions are its security boundary — an imported role\nis one whose policy is not reviewable in this repository.\n====\n\n== Stack props\n\n`cdk/bin/cdk.ts` passes:\n\n[source,typescript]\n----\nnew AgentCoreStack(app, 'AgentCoreStack', {\n  appName: 'agentcore',\n  logRetentionDays: logs.RetentionDays.ONE_MONTH,\n  secretArn: 'arn:aws:secretsmanager:{aws-region}:{aws-account}:secret:agentcore-secrets-BHnvz6',\n  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },\n});\n----\n\n`AgentCoreStackProps` in `cdk/lib/types.ts` defaults `dockerPlatform` to `LINUX_ARM64`,\n`networkMode` to `PUBLIC`, and `logRetentionDays` to `ONE_MONTH`. The `dockerPlatform` and\n`networkMode` overrides are present in `bin/cdk.ts` but commented out.\n\nThe account and region come from the CDK environment rather than being hardcoded, so\n`CDK_DEFAULT_REGION` must resolve to `{aws-region}` — Bedrock cross-region inference profiles\nfor the `us.` models depend on it.\n\n[WARNING]\n====\n`secretArn` here points at `agentcore-secrets-BHnvz6`, which is **not** the `{shared-secret}`\nsecret the Lambdas use. The two halves of the platform read different secrets. See\nxref:secrets.adoc[Secrets Manager].\n====\n\n== Constructs\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Construct | Id | Purpose\n\n| `ecr_assets.DockerImageAsset`\n| `agentcore-Image`\n| Builds and pushes the runtime container\n\n| `logs.LogGroup`\n| `agentcore-AgentLogs`\n| Application logs for the agent\n\n| `iam.Role`\n| `MemoryExecutionRole`\n| Assumed by AgentCore Memory to invoke Bedrock\n\n| `iam.Role`\n| `agentcore-RuntimeRole`\n| The runtime's execution identity\n\n| `bedrockagentcore.CfnMemory`\n| `AgentMemory`\n| Long-term memory with three extraction strategies\n\n| `bedrockagentcore.CfnRuntime`\n| `agentcore-Runtime`\n| The runtime itself\n|===\n\nTwo `CfnRuntimeEndpoint` constructs — `agentcore-ProdEndpoint` and `agentcore-DevEndpoint` —\nare written but commented out. The runtime is invoked through its ARN directly; see\nxref:agentcore:invocation.adoc[Invocation contract].\n\n== Container image\n\n[source,typescript]\n----\nnew ecr_assets.DockerImageAsset(this, `${props.appName}-Image`, {\n  directory: path.join(__dirname, '../../../agentcore'),\n  platform: dockerPlatform,   // LINUX_ARM64\n});\n----\n\nCDK builds the image locally, pushes it to the CDK-managed ECR repository, and passes the\nresulting URI to the runtime. There is no separate `docker push` step and no ECR repository\ndeclared in the stack.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Property | Value\n\n| Build context | `agentcore/`\n| Dockerfile | `agentcore/Dockerfile` (the default)\n| Platform | `linux/arm64`\n| Base image | `python:3.12-slim`\n| Working directory | `/app`\n| Dependencies | `uv sync --frozen --no-dev`\n| `PYTHONPATH` | `/app`\n| Exposed ports | `9000`, `8000`, `8080`\n| Command | `[\"python\", \"-m\", \"main\"]`\n|===\n\n`uv sync --frozen` fails rather than resolving if `uv.lock` disagrees with `pyproject.toml`,\nso a lockfile that was not committed alongside a dependency change breaks the build instead of\nproducing a differently-resolved image.\n\n[NOTE]\n====\nBuilding an `arm64` image on an `x86_64` machine requires emulation, so the first build is\nslow. The commented `LINUX_AMD64` line in `bin/cdk.ts` exists for that reason — but switching\nit changes the deployed architecture, not just the build.\n====\n\n[#memory]\n== AgentCore Memory\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Property | Value\n\n| `name` | `agentCoreMemory`\n| `eventExpiryDuration` | `90` days\n| `memoryExecutionRoleArn` | `MemoryExecutionRole`\n|===\n\nThree strategies, each writing into its own namespace:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Strategy | Name | Namespace\n\n| `semanticMemoryStrategy` | `FactExtractor` | `/facts/{actorId}/`\n| `userPreferenceMemoryStrategy` | `ContactDetailsInteractions` | `/contactDetails/{actorId}/`\n| `summaryMemoryStrategy` | `SessionSummarizer` | `/summaries/{actorId}/{sessionId}/`\n|===\n\nThe namespace templates decide the retrieval scope. Facts and contact details are keyed by\nactor alone, so they persist across every session for that prospect; summaries are keyed by\nactor *and* session, so each conversation gets its own. Ninety-day expiry then bounds how far\nback any of it reaches. Retrieval behaviour is described in\nxref:agentcore:memory.adoc[AgentCore Memory].\n\n== Runtime\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Property | Value\n\n| `agentRuntimeName` | `agentcore`\n| `agentRuntimeArtifact` | `containerConfiguration.containerUri` from the image asset\n| `protocolConfiguration` | `HTTP`\n| `networkConfiguration` | `networkMode: PUBLIC`\n| `roleArn` | `agentcore-RuntimeRole`\n|===\n\nEnvironment variables injected by CDK:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Variable | Value\n\n| `AWS_REGION` | The stack region\n| `LOG_GROUP_NAME` | `/aws/bedrock-agentcore/agents/agentcore`\n| `MEMORY_ID` | `memory.attrMemoryId`\n| `SECRET_ARN` | `props.secretArn`, only when set\n|===\n\nThat is the complete list, and it is deliberately short. Model ids, system prompts, provider\nselection, and workflow registration are **not** here — they live in runtime code under\n`agentcore/`. Infrastructure supplies coordinates (where to log, which memory, which secret);\nbehaviour is code. See xref:agentcore:configuration.adoc[Configuration].\n\n== Log group\n\n`/aws/bedrock-agentcore/agents/agentcore`, retention `ONE_MONTH`, `removalPolicy: DESTROY`.\n\nThe destroy policy means tearing down the stack deletes the logs with it. Separately, the\nAgentCore platform writes system-level logs to `/aws/bedrock-agentcore/runtimes/*`, which the\nstack does not create but does grant write access to.\n\n== Outputs\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Output | Value\n\n| `ImageUri` | ECR URI of the pushed image\n| `LogGroupName` | The agent log group\n| `AgentRuntimeId` | Runtime id\n| `AgentRuntimeArn` | Runtime ARN — the invoke target\n| `RuntimeRoleArn` | Execution role ARN\n| `SecretArn` | Emitted only when `secretArn` was supplied\n|===\n\n`AgentRuntimeArn` and `RuntimeRoleArn` are the two that matter operationally: both are copied\ninto `agentcore/.bedrock_agentcore.yaml` so the AgentCore CLI can invoke the deployed runtime.\nThat file holds a *previous* deploy's values until someone updates it, which is the usual\nreason a CLI invocation targets the wrong runtime after a stack recreate.\n\n== Modifying the stack\n\nFour changes that come up repeatedly. All are in `cdk/lib/stacks/agentcore-stack.ts` unless\nnoted.\n\n=== Add a runtime environment variable\n\n[source,typescript]\n----\nenvironmentVariables: {\n  AWS_REGION: this.region,\n  LOG_GROUP_NAME: this.logGroup.logGroupName,\n  MEMORY_ID: memory.attrMemoryId,\n  MY_NEW_SETTING: 'value',\n}\n----\n\nOnly for infrastructure coordinates. A model id, prompt, or provider choice belongs in runtime\ncode, and a credential belongs in the secret.\n\n=== Add an IAM permission\n\n[source,typescript]\n----\nruntimeRole.addToPolicy(new iam.PolicyStatement({\n  sid: 'DescriptiveSid',\n  actions: ['service:Action'],\n  resources: [specificArn],\n}));\n----\n\nName a resource ARN. If the action supports no resource-level scoping, add a `conditions`\nblock rather than accepting a bare `*` — `CloudWatchMetrics` in xref:iam.adoc[IAM] is the\npattern to copy.\n\n=== Change log retention\n\n[source,typescript]\n----\n// cdk/bin/cdk.ts\nnew AgentCoreStack(app, 'AgentCoreStack', {\n  logRetentionDays: logs.RetentionDays.THREE_MONTHS,\n});\n----\n\n=== Move the runtime into a VPC\n\n[source,typescript]\n----\n// cdk/bin/cdk.ts\nnew AgentCoreStack(app, 'AgentCoreStack', {\n  networkMode: 'VPC',\n});\n----\n\nThe prop is already threaded through `AgentCoreStackProps` and is commented out in\n`bin/cdk.ts`. Switching it also requires subnets, security groups, and VPC endpoints for ECR,\nSecrets Manager, CloudWatch Logs, and Bedrock — none of which the stack declares today. Expect\nthis to be more than a one-line change.\n\n== Commands\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Command | Effect\n\n| `make deploy-cdk` | `npx cdk bootstrap` then `cdk deploy --all`\n| `make deploy-dev` | `cdk deploy --all`, no bootstrap\n| `make deploy-prod` | `cdk deploy --all --require-approval never`\n|===\n\nUnderlying CDK CLI, run from `cdk/`:\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Command | Effect\n\n| `npx cdk diff` | Show pending changes, including an IAM summary\n| `npx cdk synth` | Emit the CloudFormation template without deploying\n| `npx cdk deploy` | Deploy or update\n| `npx cdk destroy` | Tear down — takes the log group with it (`removalPolicy: DESTROY`)\n|===\n\n`cdk diff` before any deploy that touched a policy: it is the only IAM review in the path.\n`--require-approval never` skips the confirmation prompt, so use it in automation only.\n\nDependencies in `cdk/` are managed with **pnpm**. Never run `npm install` there.\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:sam-template.adoc","title":"SAM template","description":"The single template that defines every Lambda function, layer, queue, schedule, and the shared secret.","url":"https://docx.payability.com/morpheus/infrastructure/sam-template.html","navOrder":68,"source":"= SAM template\n:description: The single template that defines every Lambda function, layer, queue, schedule, and the shared secret.\n\n`lambdas/template.yaml` declares the entire serverless half of the platform in one file —\nfunctions, layers, queues, schedules, the shared secret, and one alarm. Deployment\nconfiguration lives beside it in `lambdas/samconfig.toml`.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Transform | `AWS::Serverless-2016-10-31`\n| Stack name | `{sam-stack}` (`samconfig.toml`)\n| Capabilities | `CAPABILITY_IAM`\n| Build | `cached = true`, `parallel = true`\n| Deploy | `confirm_changeset = false`, `resolve_s3 = true`\n|===\n\nPhysical resource names are built from `${AWS::StackName}`, so every function is\n`{sam-stack}-<name>` and every queue `{sam-stack}-<name>`. Renaming the stack renames\neverything, including the queues — which would orphan any message in flight.\n\n== Parameters\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Parameter | Default | Allowed\n\n| `LogLevel` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`\n|===\n\nOne parameter, and it is the operational lever worth knowing: deploying with\n`--parameter-overrides LogLevel=DEBUG` turns on per-record logging across all five functions\nwithout a code change. Turn it back down afterwards — the debug paths log per-contact detail.\n\n== Globals\n\n[source,yaml]\n----\nGlobals:\n  Function:\n    Runtime: python3.12\n    Architectures: [x86_64]\n    Environment:\n      Variables:\n        LOG_LEVEL: !Ref LogLevel\n        SENTRY_DSN: \"\"\n        SENTRY_ENVIRONMENT: \"prod\"\n----\n\nTimeout and memory are *not* global — each function sets its own, because they differ by an\norder of magnitude between a 120-second Bedrock call and a 300-second BigQuery sync.\n\n[WARNING]\n====\n`SENTRY_DSN` is set to the empty string, which disables Sentry entirely. `configure_sentry()`\nstill runs at cold start in every handler, sees no DSN, and returns without initialising. A\nreal DSN is present in the template as a comment. Until it is set, unhandled exceptions are\nvisible only in CloudWatch. See xref:observability.adoc[Observability].\n====\n\n`Tracing: Active` is absent, so X-Ray is off for all five functions.\n\n== Functions\n\n[cols=\"1,1,1,1,3\",options=\"header\"]\n|===\n| Function | Timeout | Memory | Reserved | Layers, in order\n\n| `{sam-stack}-enrichment-scraper`\n| 300s | 256 MB | —\n| common, data-layer, bigquery\n\n| `{sam-stack}-enrichment-scorer`\n| 120s | 512 MB | 5\n| common, data-layer, llm, pyb-hubspot\n\n| `{sam-stack}-hubspot-sync`\n| 300s | 256 MB | —\n| common, data-layer, pyb-hubspot, bigquery\n\n| `{sam-stack}-email-agent`\n| 120s | 512 MB | 5\n| common, data-layer, llm\n\n| `{sam-stack}-phone-agent`\n| 120s | 256 MB | 2\n| common, data-layer, bigquery\n|===\n\nEvery handler is `handler.lambda_handler`. `MorpheusCommonLayer` is always first, because\nSentry must be configurable before anything else imports.\n\n`ReservedConcurrentExecutions` is a database protection, not a cost control. Each concurrent\ninvocation holds its own PostgreSQL connection — the data layer runs with `NullPool` — so the\nceiling on concurrency is the ceiling on connections. Phone-agent's limit of 2 is tighter still\nbecause each invocation also spends Twilio Lookup quota.\n\nThe three functions with the higher memory allocations are the ones that call Bedrock; the\nextra memory buys proportionally more CPU for JSON handling around a call that is mostly\nwaiting on the network.\n\n=== enrichment-scraper\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Environment variable | Value\n\n| `SQS_QUEUE_URL` | `EnrichmentPipelineQueue`\n| `AGENTCORE_SECRET_ARN` | `AgentcoreSecret`\n| `SMARTSCOUT_BASE_URL` | `https://smartscoutapi-east.azurewebsites.net`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n|===\n\nPolicies: `SQSSendMessagePolicy` on the enrichment queue, plus `secretsmanager:GetSecretValue`\n**and `PutSecretValue`** on the shared secret. It is the only function that can write the\nsecret — it rotates the SmartScout session token it obtains at login.\n\nActive trigger: `BigQueryContactSync` at `rate(2 hours)`. The Amazon and Walmart daily\ndiscovery schedules are commented out.\n\n=== enrichment-scorer\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Environment variable | Value\n\n| `AGENTCORE_SECRET_ARN` | `AgentcoreSecret`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n| `CRM_SYNC_QUEUE_URL` | `CrmSyncQueue`\n|===\n\nPolicies: `SQSPollerPolicy` on the enrichment queue, `SQSSendMessagePolicy` on the CRM queue,\n`secretsmanager:GetSecretValue`, and `bedrock:InvokeModel` / `InvokeModelWithResponseStream`\non `*`.\n\n[IMPORTANT]\n====\n`enrichment-scorer` currently has **no active triggers**. Its SQS event source, the\n`Seller.EnrichPending` backfill at `rate(60 hours)`, and the `Seller.ScorePending` backfill at\n`rate(4 minutes)` are all commented out.\n\nThe function is deployed and its permissions are live, but nothing invokes it: messages\naccumulate in `EnrichmentPipelineQueue` until they age out at four days. Anything that appears\nto be a scorer bug should start by confirming whether the trigger is enabled.\n====\n\nThe commented SQS event carries `ScalingConfig.MaximumConcurrency: 50` with a note explaining\nit must be raised only once RDS Proxy sits in front of the database.\n\n=== morpheus-hubspot\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Environment variable | Value\n\n| `AGENTCORE_SECRET_ARN` | `AgentcoreSecret`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n| `CONTACT_HUBSPOT_RESOLVE_BATCH_SIZE` | `25`\n|===\n\nPolicies: `SQSPollerPolicy` on `CrmSyncQueue` and `secretsmanager:GetSecretValue`. It writes\nto HubSpot over HTTPS and to PostgreSQL, neither of which needs an IAM policy.\n\nThe busiest function in the template: one SQS trigger plus ten schedules. See\nxref:schedules.adoc[EventBridge schedules].\n\n=== email-agent\n\nEnvironment: `AGENTCORE_SECRET_ARN` only — it derives its subscription from the row it loads\nrather than from configuration.\n\nPolicies: `SQSPollerPolicy` on `EmailAgentQueue`, `secretsmanager:GetSecretValue`, and Bedrock\ninvoke on `*`.\n\nTrigger: `EmailAgentQueue` with `BatchSize: 1`, `ReportBatchItemFailures`, and\n`MaximumConcurrency: 5`.\n\n=== phone-agent\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Environment variable | Value\n\n| `AGENTCORE_SECRET_ARN` | `AgentcoreSecret`\n| `SUBSCRIPTION_ID` | `{subscription-id}`\n| `CRM_SYNC_QUEUE_URL` | `CrmSyncQueue`\n| `EMAIL_AGENT_QUEUE_URL` | `EmailAgentQueue`\n| `PHONE_AGENT_BATCH_SIZE` | `25`\n| `SALES_SLACK_CHANNEL_ID` | `sales-tasks`\n|===\n\nPolicies: `SQSSendMessagePolicy` on both the CRM and email queues, plus\n`secretsmanager:GetSecretValue`. It is a producer to two queues and a consumer of none — all\nthree of its triggers are schedules.\n\n== Other resources\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Logical id | Type | Purpose\n\n| `AgentcoreSecret`\n| `AWS::SecretsManager::Secret`\n| The shared secret `{shared-secret}`, created empty. See xref:secrets.adoc[Secrets Manager].\n\n| `BedrockTokenAlarm`\n| `AWS::CloudWatch::Alarm`\n| Bedrock `InputTokenCount` above 5,000,000 in an hour\n\n| `ApplicationResourceGroup`\n| `AWS::ResourceGroups::Group`\n| Groups the stack for Application Insights\n\n| `ApplicationInsightsMonitoring`\n| `AWS::ApplicationInsights::Application`\n| `AutoConfigurationEnabled: true`\n|===\n\nThere is no API Gateway, no function URL, and no HTTP endpoint anywhere in the template.\nNothing in the Lambda half of the platform accepts an inbound request — every invocation comes\nfrom a queue or a schedule. HubSpot webhooks reach the system by writing to `CrmSyncQueue`\nthrough infrastructure outside this stack. See xref:events:hubspot-webhooks.adoc[HubSpot webhooks].\n\n== Outputs\n\nQueue URLs and ARNs for `EnrichmentPipelineQueue`, `CrmSyncQueue`, and both FIFO dead-letter\nqueues; function ARNs and generated role ARNs for scraper, scorer, hubspot, and phone-agent;\nand `AgentcoreSecretArn`.\n\nThe five `EmailAgent*` outputs are commented out even though the function is deployed — an\noversight rather than a decision.\n\n== Commented-out configuration\n\nThe template carries a substantial amount of disabled configuration: the scorer's triggers,\nthe two discovery schedules, the email-agent outputs, and production `cron(...)` expressions\nsitting next to the development `rate(...)` expressions that are actually deployed.\n\n[NOTE]\n====\nRead the template as the record of what is *deployed*, and treat a commented block as history\nrather than intent. The schedule pages call out each place where a commented production\nexpression differs from the live one — see xref:schedules.adoc[EventBridge schedules].\n====\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:queues.adoc","title":"Queues and dead-letter queues","description":"The three SQS queues, their FIFO and redrive configuration, and why a failed message goes straight to the DLQ.","url":"https://docx.payability.com/morpheus/infrastructure/queues.html","navOrder":69,"source":"= Queues and dead-letter queues\n:description: The three SQS queues, their FIFO and redrive configuration, and why a failed message goes straight to the DLQ.\n\nThree queues carry every asynchronous message in the platform. All are declared in\n`lambdas/template.yaml`.\n\n[cols=\"2,1,1,1,1\",options=\"header\"]\n|===\n| Queue | Type | Visibility | Retention | Redrive\n\n| `{sam-stack}-crm-sync.fifo`\n| FIFO\n| 360s\n| 4 days\n| `{sam-stack}-crm-sync-dlq.fifo`, `maxReceiveCount: 1`\n\n| `{sam-stack}-enrichment-pipeline.fifo`\n| FIFO\n| 180s\n| 4 days\n| `{sam-stack}-enrichment-pipeline-dlq.fifo`, `maxReceiveCount: 1`\n\n| `{sam-stack}-email-agent`\n| Standard\n| 180s\n| 4 days\n| `{sam-stack}-email-agent-dlq`, `maxReceiveCount: 1`\n|===\n\nAll three dead-letter queues retain for 14 days — the SQS maximum — so a failure has a fortnight\nto be noticed and replayed before it is lost.\n\n[#max-receive-count]\n== maxReceiveCount is 1\n\nEvery redrive policy sends a message to the dead-letter queue after a **single** failed\nreceive. There is no in-queue retry.\n\nThis is unusual and deliberate. The alternative — the common `maxReceiveCount: 3` — assumes\nfailures are transient, and for these consumers that assumption is mostly false:\n\n* A malformed or unroutable payload fails identically every time. Retrying it twice more\n  costs three invocations to reach the same destination.\n* A HubSpot 4xx is a rejection, not a blip. The `pyb-hubspot` transport already retries 429\n  responses *inside* the invocation, so rate limiting never reaches the queue as a failure.\n* A message that fails, sits invisible for the visibility timeout, and retries head-of-line\n  blocks its entire FIFO message group for the duration. With a 360-second timeout, three\n  attempts stall a group for eighteen minutes.\n\nThe trade is that a genuinely transient failure — a database connection blip — also lands in\nthe DLQ. That is accepted because a DLQ message is visible and replayable, whereas a silently\nretried-then-expired message is neither. Redriving a DLQ is described in\nxref:ROOT:operations.adoc[Operations and runbooks].\n\n== FIFO ordering\n\nThe two FIFO queues both set `ContentBasedDeduplication: false`, so every producer must supply\nan explicit `MessageDeduplicationId` and `MessageGroupId`.\n\nContent-based deduplication would hash the body — which breaks the moment a payload carries a\ntimestamp, since two logically identical events would produce different hashes and both be\ndelivered. Explicit ids let the producer choose the identity: a seller id, a contact id, an\nengagement id. The `MessageGroupId` then decides the ordering domain — messages about the same\ncontact are processed in order, messages about different contacts run in parallel.\n\nNeither queue sets `DeduplicationScope` or `FifoThroughputLimit`, so both use the defaults:\ndeduplication across the whole queue, and throughput shared across message groups rather than\nper-group high throughput. Full producer conventions are in\nxref:events:publishing.adoc[Publishing events].\n\n`EmailAgentQueue` is standard rather than FIFO because reply classification is order-independent\n— each message is one email, categorised on its own.\n\n[#visibility-timeout]\n== Visibility timeout against function timeout\n\n[cols=\"2,1,1\",options=\"header\"]\n|===\n| Queue | Visibility | Consumer timeout\n\n| `CrmSyncQueue` | 360s | `morpheus-hubspot` — 300s\n| `EnrichmentPipelineQueue` | 180s | `enrichment-scorer` — 120s\n| `EmailAgentQueue` | 180s | `email-agent` — 120s\n|===\n\n[IMPORTANT]\n====\nVisibility timeout must always exceed the consuming function's timeout, with headroom. If it\ndid not, a slow-but-successful invocation would have its message become visible again\nmid-flight and be delivered to a second invocation — the same work done twice, and with\n`maxReceiveCount: 1`, the first completion followed by a DLQ entry for the duplicate.\n\nThe margins here are 60 seconds on both. Raising a function's timeout without raising its\nqueue's visibility timeout is the most likely way to introduce duplicate processing. The\ntemplate says so in a comment above the scorer's `Timeout`.\n====\n\n== Partial batch failure\n\nEvery SQS event source declares:\n\n[source,yaml]\n----\nBatchSize: 1\nFunctionResponseTypes:\n  - ReportBatchItemFailures\n----\n\n`ReportBatchItemFailures` lets a handler return `{\"batchItemFailures\": [{\"itemIdentifier\": id}]}`\nso only the named messages are retried and the rest are deleted. Without it, one failure in a\nbatch returns the whole batch to the queue.\n\nWith `BatchSize: 1` the two are equivalent in effect, but the declaration is kept because\nraising the batch size later would otherwise silently reintroduce whole-batch retries. Batch\nsize 1 is what makes a failure attributable to one message — which is the entire premise of\nsending failures straight to a DLQ.\n\n== Concurrency\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Consumer | Maximum concurrency | Reserved concurrency\n\n| `morpheus-hubspot` | 10 | —\n| `email-agent` | 5 | 5\n| `enrichment-scorer` | 50 (commented out) | 5\n|===\n\n`ScalingConfig.MaximumConcurrency` on the event source caps how many invocations SQS starts;\n`ReservedConcurrentExecutions` on the function caps the function overall, including schedule\ninvocations. Both exist because `morpheus-hubspot` is driven by a queue *and* ten schedules —\nthe event source cap alone would not bound the total.\n\nThe HubSpot cap of 10 is set against that API's rate limits rather than any AWS constraint.\n\n== Producers and consumers\n\n[cols=\"2,2,2\",options=\"header\"]\n|===\n| Queue | Produced by | Consumed by\n\n| `CrmSyncQueue`\n| `enrichment-scorer`, `phone-agent`, HubSpot webhooks\n| `morpheus-hubspot`\n\n| `EnrichmentPipelineQueue`\n| `enrichment-scraper`\n| `enrichment-scorer` — **trigger currently disabled**\n\n| `EmailAgentQueue`\n| `phone-agent`\n| `email-agent`\n|===\n\nPayload schemas for each queue are in xref:events:crm-sync-queue.adoc[CrmSyncQueue events],\nxref:events:enrichment-pipeline-queue.adoc[EnrichmentPipelineQueue events], and\nxref:events:email-agent-queue.adoc[EmailAgentQueue events].\n\n[WARNING]\n====\n`enrichment-scorer`'s SQS event source is commented out in the template, so\n`EnrichmentPipelineQueue` has a producer and no consumer. Messages accumulate until the 4-day\nretention expires and are then dropped — with no DLQ entry, because expiry is not a delivery\nfailure. Check `ApproximateNumberOfMessagesVisible` on that queue before assuming the\nenrichment pipeline is healthy. See xref:sam-template.adoc[SAM template].\n====\n\n== Monitoring\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Metric | Meaning\n\n| `ApproximateNumberOfMessagesVisible` (DLQ)\n| Anything above zero is a failure that needs a human. Nothing alarms on this today.\n\n| `ApproximateNumberOfMessagesVisible` (main)\n| Sustained growth means the consumer is throttled, disabled, or erroring.\n\n| `ApproximateAgeOfOldestMessage`\n| The lead indicator. Approaching four days means messages are about to expire silently.\n|===\n\nNo CloudWatch alarm is configured on any queue metric. The only alarm in the stack watches\nBedrock token consumption — see xref:observability.adoc[Observability].\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:schedules.adoc","title":"EventBridge schedules","description":"Every scheduled rule in the SAM template, its expression, its Input payload, and the handler it reaches.","url":"https://docx.payability.com/morpheus/infrastructure/schedules.html","navOrder":70,"source":"= EventBridge schedules\n:description: Every scheduled rule in the SAM template, its expression, its Input payload, and the handler it reaches.\n\nFourteen schedules drive the platform. Each is an EventBridge rule declared as an event on a\nfunction in `lambdas/template.yaml`, and each carries a static `Input` JSON document that\nbecomes the entire event the handler receives.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Role\n\n| `detail-type`\n| The routing key. The function's `registry.py` dispatches on this and nothing else.\n\n| `source`\n| Origin label, for readability and CloudWatch filtering.\n\n| `detail`\n| Usually `{}` — a scheduled handler derives its work from the database, not the payload.\n|===\n\nBecause the `Input` is static, a schedule is a *trigger*, never a parameter carrier. Anything a\nhandler needs to know it looks up: the watermark from `configs`, the pending rows from\nPostgreSQL, the segment from BigQuery.\n\n[IMPORTANT]\n====\nThe `detail-type` string in `template.yaml` must match a `registry.register(...)` key exactly.\nA typo produces no error at deploy time and no exception at runtime — the handler is simply\nnever found, and the schedule fires into nothing. See\nxref:events:adding-an-event.adoc[Adding a new event].\n====\n\n== morpheus-hubspot\n\nTen of the fourteen. Rule names are prefixed `{sam-stack}-`.\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Rule | Expression | `detail-type`\n\n| `hubspot-task-poll`\n| `rate(30 minutes)`\n| `Hubspot.FollowUpTaskPoll`\n\n| `hubspot-contact-resolve`\n| `rate(30 minutes)`\n| `Hubspot.ContactResolve`\n\n| `bigquery-engagement-sync`\n| `cron(0 9,20 * * ? *)`\n| `Hubspot.BigQueryEngagementSync`\n\n| `five9-call-sync`\n| `cron(0 9,20 * * ? *)`\n| `Hubspot.Five9CallSync`\n\n| `email-click-reengagement`\n| `rate(6 hours)`\n| `Hubspot.EmailClickReEngagement`\n\n| `supplier-account-reengagement`\n| `rate(6 hours)`\n| `Hubspot.SupplierAccountReEngagement`\n\n| `unattended-meeting-reengagement`\n| `rate(6 hours)`\n| `Hubspot.UnattendedMeetingReEngagement`\n\n| `lost-deal-reengagement`\n| `rate(6 hours)`\n| `Hubspot.LostDealReEngagement`\n\n| `sales-rep-call-list-refresh`\n| `rate(6 hours)`, timezone `America/New_York`\n| `Hubspot.SalesRepCallListRefresh`\n|===\n\nThe two `cron(0 9,20 * * ? *)` jobs run at 09:00 and 20:00 UTC — before and after the US\nbusiness day, which is when the upstream exports have settled.\n\nThe four re-engagement schedules share a `rate(6 hours)` cadence and therefore fire together.\nThat simultaneity is why the round-robin cursor is taken under an advisory lock: four handlers\nassigning reps at the same instant would otherwise all read the same cursor value. See\nxref:business:ownership.adoc[Ownership and round-robin].\n\n=== The one ScheduleV2\n\n`SalesRepCallListRefresh` is the only rule declared as `Type: ScheduleV2` rather than\n`Type: Schedule`, because it is the only one that needs `ScheduleExpressionTimezone`. It runs\nin `America/New_York` so the list rebuild tracks the sales team's working day across daylight\nsaving transitions, which a UTC cron cannot do.\n\nThe production expression `cron(0 6 * * ? *)` — a single daily rebuild at 06:00 Eastern — is\npresent but commented out in favour of `rate(6 hours)`. See\nxref:business:call-lists.adoc[Daily call lists].\n\n== phone-agent\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Rule | Expression | `detail-type`\n\n| `phone-line-intel-twice-daily`\n| `rate(3 hours)`\n| `Phone.LineIntelPending`\n\n| `task-deadline-reminder`\n| `cron(0 8,14,20 * * ? *)`\n| `Phone.TaskDeadlineReminder`\n\n| `campaign-reengagement`\n| `rate(6 hours)`\n| `Phone.CampaignTargetReEngagement`\n|===\n\n`phone-line-intel-twice-daily` is named for a cadence it no longer runs — the commented\nproduction expression is `cron(0 6,18 * * ? *)`, but the deployed one is `rate(3 hours)`,\neight times a day. Each run processes up to `PHONE_AGENT_BATCH_SIZE` (25) numbers through\nTwilio Lookup, so the deployed rate spends roughly four times the intended quota. See\nxref:business:phone-intelligence.adoc[Phone line intelligence].\n\n`task-deadline-reminder` at 08:00, 14:00, and 20:00 UTC drives escalation: each run finds\n`PENDING` tasks past due with `escalationStage < 3` and notifies Slack. Three runs a day\nagainst a cap of three stages means a stale task is escalated to exhaustion within 24 hours.\nSee xref:business:follow-up-tasks.adoc[Follow-up tasks and escalation].\n\n`campaign-reengagement` carries a commented production expression of `cron(0 12 * * ? *)`.\n\n== enrichment-scraper\n\n[cols=\"2,1,2\",options=\"header\"]\n|===\n| Rule | Expression | `detail-type`\n\n| `bq-contact-sync`\n| `rate(2 hours)`\n| `Scraper.BigQueryContactSync`\n|===\n\nThe scraper's *discovery* schedules are commented out:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Rule | Expression | `detail-type`\n\n| `AmazonDailyDiscovery` | `cron(0 7 * * ? *)` | `SellerDiscovery.Amazon`\n| `WalmartDailyDiscovery` | `cron(0 8 * * ? *)` | also `Enabled: false`\n|===\n\nThe Amazon rule's `detail` carries `{\"marketplace\":\"amazon\",\"provider\":\"smartscout\"}` — one of\nthe few schedules that does parameterise its handler.\n\n[NOTE]\n====\nWith both discovery schedules disabled, no new sellers enter the funnel. The scraper's only\nlive job is mirroring HubSpot contacts from BigQuery into PostgreSQL. Together with the\ndisabled scorer triggers, the entire discovery-and-enrichment half of the pipeline is dormant;\nthe re-engagement and CRM half is not. See xref:business:funnel.adoc[Outbound funnel].\n====\n\n== enrichment-scorer\n\nNo active schedules. Both backfills are commented out:\n\n[cols=\"1,1,2\",options=\"header\"]\n|===\n| Rule | Expression | `detail-type`\n\n| `EnrichPendingBackfill` | `rate(60 hours)` | `Seller.EnrichPending`\n| `ScorePendingBackfill` | `rate(4 minutes)` | `Seller.ScorePending`\n|===\n\n`rate(4 minutes)` is a development value — at 10 sellers per run it would process 3,600 a day\nand hold a Bedrock call open most of the time. Re-enabling it as written would be expensive;\nthe `rate(60 hours)` enrichment backfill is the more plausible production cadence of the two.\n\n== Reading the expressions\n\n`rate(n units)` measures from the *previous invocation's completion*, so a `rate(6 hours)` job\nthat takes ten minutes runs every six hours and ten minutes and drifts. `cron(...)` fires at\nfixed wall-clock times and does not drift.\n\nThe distinction matters where two jobs must not overlap. The four re-engagement handlers are\nall `rate(6 hours)` and drift together, which is fine because each is independently guarded;\nthe two `cron(0 9,20 * * ? *)` syncs are pinned because they read a shared watermark window.\n\nAll `cron(...)` expressions are UTC unless the rule sets `ScheduleExpressionTimezone`, which\nonly `SalesRepCallListRefresh` does.\n\n== Changing a schedule\n\n. Edit the expression in `lambdas/template.yaml`.\n. If the `detail-type` changes, update the matching `registry.register(...)` call — nothing\n  validates the pair.\n. `make deploy-lambdas`.\n. Update this page and xref:events:scheduled.adoc[Scheduled events] in the same pull request.\n\nDisabling a schedule without a deploy is possible from the EventBridge console, but the change\nis invisible in the repository. For the scraper there is a better lever: the\n`enrichment.<marketplace>.disabled` key in the `configs` table, which the handler checks on\nevery run. See xref:data:configs-table.adoc[Runtime state in `configs`].\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:layers.adoc","title":"Lambda layers","description":"The five shared libraries packaged as layers, how they are built, and why their attachment order matters.","url":"https://docx.payability.com/morpheus/infrastructure/layers.html","navOrder":71,"source":"= Lambda layers\n:description: The five shared libraries packaged as layers, how they are built, and why their attachment order matters.\n\nEach library in `libs/` is published as a Lambda layer by the SAM template. A function\nattaches only the layers it needs.\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Logical id | Layer name | Source\n\n| `MorpheusCommonLayer` | `{sam-stack}-common` | `libs/morpheus-common/`\n| `MorpheusLlmLayer` | `{sam-stack}-llm` | `libs/morpheus-llm/`\n| `MorpheusDataLayer` | `{sam-stack}-data-layer` | `libs/morpheus-data-layer/`\n| `MorpheusBigQueryLayer` | `{sam-stack}-bigquery` | `libs/morpheus-bigquery/`\n| `PybHubspotLayer` | `{sam-stack}-pyb-hubspot` | `libs/pyb-hubspot/`\n|===\n\nAll five declare `CompatibleRuntimes: [python3.12]`, `CompatibleArchitectures: [x86_64]`,\n`BuildMethod: python3.12`, and `RetentionPolicy: Retain`.\n\n`RetentionPolicy: Retain` keeps every published version alive after a stack update. Layer\nversions are immutable and a function pins one, so deleting old versions would break any\nfunction still referencing them — including during a rollback.\n\n== Attachment\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Function | Layers, in order\n\n| `enrichment-scraper` | common, data-layer, bigquery\n| `enrichment-scorer` | common, data-layer, llm, pyb-hubspot\n| `morpheus-hubspot` | common, data-layer, pyb-hubspot, bigquery\n| `email-agent` | common, data-layer, llm\n| `phone-agent` | common, data-layer, bigquery\n|===\n\n[IMPORTANT]\n====\n`MorpheusCommonLayer` is **always first**. Layers are extracted onto the path in order, and\n`morpheus_common` provides `configure_sentry()`, which every handler calls at cold start\nbefore it loads secrets or imports anything else. Ordering it after a heavier layer risks an\nimport failing before error reporting is available to record it.\n\nThe conventional order after that is data-layer, then llm, then the integration layers.\n====\n\n`morpheus-common` is on all five functions; `morpheus-data-layer` is too. `morpheus-llm` is\nattached only to the two functions that call Bedrock, `morpheus-bigquery` only to the three\nthat query the warehouse, and `pyb-hubspot` only to the two that talk to the CRM.\n\nKeeping the set minimal is not cosmetic: layers are extracted at cold start, and every\nmegabyte of unused dependency is latency paid on every cold invocation. `morpheus-llm` pulls\nin the Strands SDK and its transitive dependencies, which is why `phone-agent` — which needs\nnone of it — does not carry it.\n\n== How they are built\n\n`BuildMethod: python3.12` tells `sam build` to install each layer's dependencies with pip into\na `python/` directory inside the layer archive, which is where the Lambda runtime looks.\n\nThe dependency list itself comes from the `requirements.txt` generated ahead of the build by\n`lambdas/Makefile`:\n\n[source,bash]\n----\nuv export --no-dev --no-hashes -o requirements.txt\n----\n\n`make deploy` runs that export for every function before `sam build --no-cached && sam deploy`.\n\n[NOTE]\n====\nLibraries are developed with `uv` and deployed with `pip`. The `uv export` step is the bridge:\n`pyproject.toml` and `uv.lock` are the source of truth, and `requirements.txt` is a build\nartefact regenerated on every deploy.\n\nThat is why a hand-edited `requirements.txt` silently disappears — the next `make deploy`\noverwrites it. Add the dependency with `uv add` in the library directory instead.\n====\n\n`--no-cached` forces a clean build. Layer builds cache aggressively, and a cached layer that\npredates a source change is a genuinely confusing failure: the deploy succeeds and the old\ncode runs.\n\n== Importing from a layer\n\nA layer's package is a normal top-level import — the extraction path is already on\n`sys.path`:\n\n[source,python]\n----\nfrom morpheus_common.sentry import configure as configure_sentry\nfrom morpheus_data_layer.repositories import SellerRepository\nfrom morpheus_data_layer.session import get_session, init\nfrom morpheus_llm.providers import ClaudeAgentService\nfrom morpheus_bigquery import create_bigquery_client\nfrom pyb_hubspot import ...\n----\n\nNever import from `agentcore.*` or from another Lambda's package. The deployment units are\nindependent, and the only shared code is what is packaged as a layer.\n\n== Testing without a layer\n\nTests run on a developer machine where no layer is installed. The pattern that makes that work\nis a deferred import inside an `@lru_cache` function:\n\n[source,python]\n----\n@lru_cache(maxsize=1)\ndef load_secrets() -> Secrets:\n    import boto3                       # <1>\n    ...\n----\n<1> Imported when first called, not at module load.\n\nThe module can then be imported by a test that mocks `load_secrets()` entirely, without the\nlayer's dependencies being present. The caching is doing double duty: one Secrets Manager call\nper container lifetime, and one import. See xref:lambdas:testing.adoc[Testing Lambdas].\n\n== Adding a layer\n\n. Create the library under `libs/<name>/` with a `pyproject.toml`. Manage dependencies with\n  `uv add`.\n. Add an `AWS::Serverless::LayerVersion` to `lambdas/template.yaml` with `ContentUri`,\n  `CompatibleRuntimes: [python3.12]`, `CompatibleArchitectures: [x86_64]`,\n  `BuildMethod: python3.12`, and `RetentionPolicy: Retain`.\n. Add a `sync-<name>` step to `lambdas/Makefile` so the export runs before the build.\n. Reference it from each function's `Layers` list, after `MorpheusCommonLayer`.\n. Document it in xref:libs:index.adoc[Shared libraries] and add a row to this page.\n\n== Constraints\n\nLayers are versioned and immutable::\n`sam deploy` publishes a new version and repoints the functions. Rolling back republishes an\nearlier one.\n\nFive layers is the hard limit::\nAWS allows a maximum of five layers per function. `enrichment-scorer` and `morpheus-hubspot`\ncarry four. A sixth shared library cannot be attached to those functions without merging two.\n\n250 MB unzipped, all layers plus code::\nThe Lambda deployment size limit is shared across the function and every layer. `morpheus-llm`\nis the largest contributor.\n\nArchitecture must match::\nLayers declare `x86_64`, matching the SAM `Globals`. A function switched to `arm64` will fail\nto load every one of them.\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:secrets.adoc","title":"Secrets Manager","description":"The two secrets, which stack owns each, how they are loaded and cached, and what deploying does not do.","url":"https://docx.payability.com/morpheus/infrastructure/secrets.html","navOrder":72,"source":"= Secrets Manager\n:description: The two secrets, which stack owns each, how they are loaded and cached, and what deploying does not do.\n\nEvery credential the platform uses comes from AWS Secrets Manager at runtime. Nothing is baked\ninto an environment variable, a container image, or a template.\n\nThere are **two** secrets, and the two halves of the platform read different ones.\n\n[cols=\"2,1,2,2\",options=\"header\"]\n|===\n| Secret | Created by | Read by | Referenced as\n\n| `{shared-secret}`\n| SAM, as `AgentcoreSecret`\n| All five Lambda functions\n| `AGENTCORE_SECRET_ARN` environment variable\n\n| `agentcore-secrets-BHnvz6`\n| Nothing — pre-existing\n| The AgentCore runtime\n| `SECRET_ARN`, from the `secretArn` CDK prop\n|===\n\n[WARNING]\n====\nThis split is easy to miss. Adding a key to `{shared-secret}` does nothing for the AgentCore\nruntime, and adding it to the CDK secret does nothing for the Lambdas. A credential needed on\nboth sides has to be written to both.\n\nThe CDK secret's ARN is a hardcoded literal in `cdk/bin/cdk.ts`, including its random suffix.\nRecreating that secret changes the suffix and breaks the deploy until the literal is updated.\n====\n\nThe exact key list for `{shared-secret}` is in\nxref:ROOT:configuration.adoc#the-shared-secret[Configuration and secrets].\n\n== The SAM resource\n\n[source,yaml]\n----\nAgentcoreSecret:\n  Type: AWS::SecretsManager::Secret\n  Properties:\n    Name: !Sub \"agentcore-configs\"\n    Description: Shared API credentials for the agentcore pipeline\n----\n\nNo `GenerateSecretString`, no `SecretString`. SAM creates the secret **empty**.\n\n[IMPORTANT]\n====\nDeploying gives you a secret with no value in it. Every function will then fail at cold start\nwhen it tries to parse the payload. Populating it is a manual post-deploy step, and it is the\nsingle most common reason a fresh environment does not work:\n\n[source,bash,subs=attributes+]\n----\naws secretsmanager put-secret-value \\\n  --secret-id {shared-secret} \\\n  --secret-string file://secrets.json \\\n  --region {aws-region}\n----\n\nDelete the local file afterwards. Never commit it — `.env` files and credential JSON are\ncovered by the hard rules in `AGENTS.md`.\n====\n\nKeeping the value out of the template is what allows the template to be public and reviewable.\nThe consequence is that the secret's contents are not versioned anywhere, so a key added by\none person is invisible to everyone else until something breaks. When you add one, document it\nin xref:ROOT:configuration.adoc[Configuration and secrets] in the same pull request.\n\n== Loading in a Lambda\n\n[source,python]\n----\nfrom functools import lru_cache\n\n@lru_cache(maxsize=1)\ndef load_secrets() -> Secrets:\n    import boto3\n    client = boto3.client(\"secretsmanager\")\n    raw = client.get_secret_value(SecretId=os.environ[\"AGENTCORE_SECRET_ARN\"])\n    return Secrets.model_validate_json(raw[\"SecretString\"])\n----\n\nThree things are load-bearing here:\n\n`@lru_cache(maxsize=1)`::\nOne Secrets Manager API call per container lifetime, not per invocation. A warm container that\nhandles a thousand messages makes one call. Secrets Manager charges per API call and throttles,\nso an uncached fetch on a queue consumer at concurrency 10 is both a cost and a rate-limit\nproblem.\n\nThe deferred `import boto3`::\nNothing is imported at module load, so a test can import the module and patch `load_secrets()`\nwithout AWS or the layer being present. See xref:lambdas:testing.adoc[Testing Lambdas].\n\nPydantic validation::\nThe payload is parsed into a typed model, so a missing key fails at cold start with a clear\nvalidation error rather than as a `KeyError` deep inside a handler an hour later.\n\n[NOTE]\n====\nThe cache is per *container*, so rotating a secret does not take effect until every warm\ncontainer is recycled. There is no cache invalidation and no rotation configured. To force a\npickup immediately, publish a trivial function configuration change — updating an environment\nvariable replaces all containers.\n====\n\nThe call order at cold start is fixed: `configure_sentry()` first, then `load_secrets()`. If\nloading the secret throws, Sentry is already initialised to report it.\n\n== Loading in the AgentCore runtime\n\n`agentcore/config/loader.py` exposes `load_config()`, which fetches `SECRET_ARN`, validates it\nwith Pydantic, and injects the values into the process environment before the workflow registry\nis built. It runs once, from `main.py`, before `app.run()`.\n\nThe injection step exists because third-party SDKs read their own environment variables —\nan Anthropic client wants `ANTHROPIC_API_KEY` in `os.environ`, not passed as an argument. See\nxref:agentcore:configuration.adoc[Configuration].\n\n== Permissions\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Principal | Grant\n\n| `enrichment-scraper`\n| `secretsmanager:GetSecretValue` **and `PutSecretValue`** on `AgentcoreSecret`\n\n| The other four functions\n| `secretsmanager:GetSecretValue` on `AgentcoreSecret`\n\n| AgentCore runtime role\n| `GetSecretValue` and `DescribeSecret`, scoped to `props.secretArn` when set, otherwise to\n  `arn:aws:secretsmanager:{aws-region}:{aws-account}:secret:*`\n|===\n\n`enrichment-scraper` is the only writer. It logs into SmartScout, receives a session token, and\nwrites it back so the next invocation reuses it instead of re-authenticating. That is a genuine\nrequirement, but it means one function can overwrite the entire shared secret — the widest\nsingle permission in the platform.\n\nThe runtime role's fallback to a wildcard resource only applies when `secretArn` is unset. It\nis set in `cdk/bin/cdk.ts`, so the deployed policy is scoped to one ARN. Removing that prop\nwould silently widen the grant to every secret in the account. More in xref:iam.adoc[IAM].\n\n== Never in a secret\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Value | Where it belongs\n\n| Model ids, prompts, provider choice\n| Runtime code. Never in CDK, SAM, or a secret.\n\n| `SUBSCRIPTION_ID`\n| A plain environment variable — it is an identifier, not a credential.\n\n| Queue URLs, log group names, batch sizes\n| Environment variables from the template.\n\n| Watermarks, cursors, kill switches\n| The `configs` table. See xref:data:configs-table.adoc[Runtime state in `configs`].\n|===\n\nThe dividing line: a secret holds things that would cause harm if disclosed. Everything else is\nconfiguration, and configuration belongs somewhere reviewable.\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:iam.adoc","title":"IAM","description":"The runtime role's inline policies, the SAM-generated function roles, and where the permission model is wide.","url":"https://docx.payability.com/morpheus/infrastructure/iam.html","navOrder":73,"source":"= IAM\n:description: The runtime role's inline policies, the SAM-generated function roles, and where the permission model is wide.\n\nEvery IAM identity is created by one of the two stacks. Neither imports an existing role.\n\n[IMPORTANT]\n====\n`iam.Role.fromRoleArn()` is banned in CDK. A role imported by ARN has a policy defined outside\nthis repository, which means a permission change never appears in a pull request. CDK owns\nevery identity it attaches, so the blast radius of the runtime is reviewable in one file.\n====\n\n== Roles\n\n[cols=\"2,1,3\",options=\"header\"]\n|===\n| Role | Created by | Assumed by\n\n| `agentcore-RuntimeRole`\n| CDK\n| `bedrock-agentcore.amazonaws.com` — the AgentCore runtime\n\n| `MemoryExecutionRole`\n| CDK\n| `bedrock-agentcore.amazonaws.com` — AgentCore Memory\n\n| Five `*FunctionRole` roles\n| SAM\n| `lambda.amazonaws.com`\n|===\n\nNeither CDK role sets an explicit `roleName`, so CloudFormation generates physical names such\nas `AgentCoreStack-agentcoreRuntimeRole3DA72390-og2sjxN8EWYw`. That name changes if the role\nis replaced, which is why `agentcore/.bedrock_agentcore.yaml` pins the ARN and has to be\nrefreshed after a stack recreate.\n\nNo AWS managed policies are attached to either CDK role. Every permission is an inline\nstatement.\n\n== MemoryExecutionRole\n\nOne statement, in an inline policy named `MemoryPolicy`:\n\n[cols=\"2,1\",options=\"header\"]\n|===\n| Actions | Resources\n\n| `bedrock:InvokeModel`, `bedrock:InvokeModelWithResponseStream` | `*`\n|===\n\nAgentCore Memory assumes this role to run its own extraction strategies — the fact extractor,\nthe preference extractor, and the summariser each invoke a model to condense a session. See\nxref:cdk-stack.adoc#memory[AgentCore Memory].\n\n== agentcore-RuntimeRole\n\nInline policy `RuntimeAccessPolicy`, ten statements:\n\n[cols=\"1,2,2\",options=\"header\"]\n|===\n| Sid | Actions | Resources\n\n| `ECRImageAccess`\n| `ecr:BatchGetImage`, `ecr:GetDownloadUrlForLayer`\n| `arn:aws:ecr:{aws-region}:{aws-account}:repository/*`\n\n| `ECRTokenAccess`\n| `ecr:GetAuthorizationToken`\n| `*` — the action does not support resource scoping\n\n| `CloudWatchLogsManage`\n| `logs:CreateLogGroup`, `DescribeLogGroups`, `DescribeLogStreams`\n| `arn:aws:logs:{aws-region}:{aws-account}:log-group:*`\n\n| `CloudWatchLogsWrite`\n| `logs:CreateLogStream`, `PutLogEvents`\n| `/aws/bedrock-agentcore/runtimes/*` and the stack's own log group, both `:log-stream:*`\n\n| `XRayTracing`\n| `xray:PutTraceSegments`, `PutTelemetryRecords`, `GetSamplingRules`, `GetSamplingTargets`\n| `*`\n\n| `CloudWatchMetrics`\n| `cloudwatch:PutMetricData`\n| `*`, conditioned on `cloudwatch:namespace` equalling `bedrock-agentcore`\n\n| `AgentCoreWorkloadIdentity`\n| `bedrock-agentcore:GetWorkloadAccessToken`, `…ForJWT`, `…ForUserId`\n| The default workload-identity directory and `…/workload-identity/agentName-*`\n\n| `BedrockModelInvocation`\n| `bedrock:InvokeModel`, `InvokeModelWithResponseStream`\n| `arn:aws:bedrock:*::foundation-model/*` and `arn:aws:bedrock:{aws-region}:{aws-account}:*`\n\n| `MarketplaceModelAccess`\n| `aws-marketplace:Subscribe`, `Unsubscribe`, `ViewSubscriptions`\n| `*`\n\n| `SecretsManagerRead`\n| `secretsmanager:GetSecretValue`, `DescribeSecret`\n| `[props.secretArn]` when set, otherwise `…:secret:*`\n|===\n\nPlus a memory statement added through `addToPolicy`:\n\n[cols=\"2,2\",options=\"header\"]\n|===\n| Actions | Resources\n\n| `bedrock-agentcore:CreateEvent`, `ListEvents`, `GetEvent`, `RetrieveMemoryRecords`,\n  `ListMemoryRecords`, `GetMemory`\n| `[memory.attrMemoryArn]`\n|===\n\nThree of these deserve comment.\n\n`BedrockModelInvocation` spans all regions::\nThe foundation-model resource is `arn:aws:bedrock:*::foundation-model/*`, with a wildcard\nregion. That is required, not sloppy: the `us.` model ids are cross-region inference profiles,\nand the underlying invocation is dispatched to whichever region serves the request. Pinning it\nto `{aws-region}` would break every model in `AgentModelIds`.\n\n`CloudWatchMetrics` is conditioned, not scoped::\n`cloudwatch:PutMetricData` cannot be restricted by resource, so the policy restricts it by\ncondition instead — the runtime can only publish into the `bedrock-agentcore` namespace. This\nis the correct pattern for actions with no resource-level control, and it is the only place\nthe stack uses it.\n\n`MarketplaceModelAccess` grants `Subscribe`::\nThe runtime can subscribe the account to a Marketplace model offering, which has a billing\nconsequence. It is present because first use of certain Bedrock models requires an\nimplicit subscription. It is the widest grant in the policy.\n\n== SAM function roles\n\nSAM generates one role per function from its `Policies` block. The policies in use:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Policy | Effect\n\n| `SQSPollerPolicy: { QueueName: … }`\n| `ReceiveMessage`, `DeleteMessage`, `GetQueueAttributes` on one named queue\n\n| `SQSSendMessagePolicy: { QueueName: … }`\n| `SendMessage` on one named queue\n\n| Inline `secretsmanager:GetSecretValue`\n| Scoped to `AgentcoreSecret` by `!Ref`\n\n| Inline `secretsmanager:PutSecretValue`\n| `enrichment-scraper` only\n\n| Inline `bedrock:InvokeModel`, `InvokeModelWithResponseStream`\n| Resource `\"*\"` — `enrichment-scorer` and `email-agent` only\n|===\n\nThe SAM policy templates are resource-scoped by construction: a function that sends to\n`CrmSyncQueue` cannot receive from it, and cannot touch `EnrichmentPipelineQueue` at all. The\nproducer/consumer topology in xref:queues.adoc[Queues and dead-letter queues] is enforced by\nIAM, not just by convention.\n\n[NOTE]\n====\nThe Bedrock statements use `Resource: \"*\"` rather than naming model ARNs. Same reason as the\nruntime role: cross-region inference profiles resolve to region-specific ARNs that cannot be\nenumerated up front. Narrowing it would need a wildcard on the region regardless.\n====\n\nThree things the functions notably do **not** have IAM permission for, because those systems\ndo not use IAM: PostgreSQL (a connection string in the secret), HubSpot (an OAuth token or\nprivate app token), and BigQuery (a Google service-account JSON). Losing the AWS role does not\nprotect any of them — see xref:ROOT:security.adoc#credentials[credentials].\n\n== Where the model is wide\n\nWorth knowing, and each is a deliberate trade rather than an oversight:\n\n[cols=\"2,3\",options=\"header\"]\n|===\n| Grant | Reason\n\n| `bedrock:InvokeModel` on `*`\n| Cross-region inference profiles\n\n| `aws-marketplace:Subscribe`\n| First-use model subscription; has a billing effect\n\n| `PutSecretValue` on the whole shared secret\n| The scraper rotates its SmartScout session token; the API cannot scope to one JSON key\n\n| `logs:CreateLogGroup` on `log-group:*`\n| The runtime creates its own per-session log groups\n|===\n\nThe corresponding narrow points are real too: every queue permission names one queue, secret\nreads name one secret, and memory actions name one memory ARN.\n\n== Reviewing a change\n\n. `cdk diff` prints an IAM change summary and, without `--require-approval never`, prompts\n  before applying it. Read the summary — it is the only IAM review in the deploy path.\n. `sam deploy` requires `CAPABILITY_IAM`, which is set in `samconfig.toml` and therefore never\n  prompts. Inspect the changeset when a `Policies` block changed.\n. Prefer a SAM policy template over an inline statement. `SQSPollerPolicy` cannot be\n  accidentally over-scoped; a hand-written statement can.\n. If an action supports no resource scoping, add a `Condition` instead of accepting `*`.\n"},{"component":"morpheus","module":"infrastructure","path":"infrastructure:observability.adoc","title":"Observability","description":"What the platform actually reports — Sentry, CloudWatch logs, the one alarm — and the gaps in the current setup.","url":"https://docx.payability.com/morpheus/infrastructure/observability.html","navOrder":74,"source":"= Observability\n:description: What the platform actually reports — Sentry, CloudWatch logs, the one alarm — and the gaps in the current setup.\n\nObservability is thinner than the rest of the infrastructure, and knowing exactly where the\ngaps are is more useful than a list of features.\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Signal | Status | Notes\n\n| Structured logs | Active | CloudWatch, per function, no retention policy\n| Sentry error reporting | **Disabled** | `SENTRY_DSN` is `\"\"` in the SAM template\n| X-Ray tracing | Off | No `Tracing: Active`; the runtime role has the permissions\n| CloudWatch alarms | One | Bedrock token consumption\n| Application Insights | Auto-configured | On the SAM stack resource group\n| Dashboards | None | —\n|===\n\n== Sentry\n\n`libs/morpheus-common/morpheus_common/sentry.py` exposes `configure()`, and every Lambda\nhandler calls it at cold start before anything else — including before `load_secrets()`, so a\nfailure to load the secret is itself reportable.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Environment variable | Behaviour\n\n| `SENTRY_DSN` | Empty or absent → `configure()` returns immediately, no initialisation\n| `SENTRY_ENVIRONMENT` | `\"prod\"` from the template; the code default is `\"production\"`\n| `SENTRY_RELEASE` | Optional, passed through to `sentry_sdk.init(release=...)`\n| `SENTRY_TRACES_SAMPLE_RATE` | Documented as `0.1`, but the `traces_sample_rate` argument is commented out\n|===\n\nIntegrations: `AwsLambdaIntegration(timeout_warning=True)` and\n`LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)`. `send_default_pii` is\n`False`.\n\n[WARNING]\n====\n`SENTRY_DSN: \"\"` in `Globals` means Sentry is **off in production**. Handlers call\n`configure_sentry()`, it finds no DSN, and returns. A real DSN is present in `template.yaml`\nas a comment.\n\nUntil it is set, an unhandled exception exists only as a CloudWatch log line. Nothing pages,\nnothing aggregates, and a failure that lands in a dead-letter queue is invisible until someone\nlooks at the queue.\n====\n\nThe two configured integrations are worth understanding for when it is turned on:\n`timeout_warning=True` reports a Lambda approaching its timeout — the failure mode that\notherwise produces no exception at all, just a truncated invocation. And the logging\nintegration's split levels mean `logger.info()` calls become breadcrumbs attached to an\nevent, while `logger.error()` creates the event. That is why the convention is\n`logger.error(..., exc_info=True)` on every caught exception: it is what carries the stack\ntrace into the report.\n\n`send_default_pii=False` matters here specifically. These handlers process contact names,\nemail addresses, and phone numbers; the default-on setting would attach request data to every\nevent. See xref:ROOT:security.adoc#pii[PII].\n\n== CloudWatch logs\n\n[cols=\"2,2,1\",options=\"header\"]\n|===\n| Source | Log group | Retention\n\n| Lambda functions | `/aws/lambda/{sam-stack}-<name>` | **Never expires**\n| AgentCore agent | `/aws/bedrock-agentcore/agents/agentcore` | One month\n| AgentCore platform | `/aws/bedrock-agentcore/runtimes/*` | Platform-managed\n|===\n\n[NOTE]\n====\nThe SAM template sets no retention on any Lambda log group, so they default to never expiring\nand accumulate cost indefinitely. The CDK log group does set `ONE_MONTH`. The asymmetry is an\noversight, not a decision.\n====\n\nLogging conventions the pages elsewhere depend on:\n\n* `logger = logging.getLogger(__name__)`, never `print()`.\n* Structured fields as `key=value` in the message string — `\"sellerId=%r stage=%r\"` — so\n  CloudWatch Logs Insights can filter on them.\n* Never log a full invocation payload. They carry PII.\n* `logger.debug()` for per-record detail, which the `LogLevel` stack parameter turns on\n  without a code change. See xref:sam-template.adoc[SAM template].\n\nTailing a function:\n\n[source,bash,subs=attributes+]\n----\naws logs tail /aws/lambda/{sam-stack}-hubspot-sync --follow --region {aws-region}\n----\n\nMore query recipes in xref:ROOT:operations.adoc[Operations and runbooks].\n\n== X-Ray\n\nNot enabled. The SAM `Globals` block has no `Tracing: Active`, so no function is traced.\n\nThe AgentCore runtime role *does* carry `xray:PutTraceSegments`, `PutTelemetryRecords`,\n`GetSamplingRules`, and `GetSamplingTargets`. Whether traces are produced depends on the\nAgentCore platform's own configuration rather than on anything in this repository.\n\nTracing would be most valuable on the paths that cross services — a `CrmSyncQueue` message\nproduced by `enrichment-scorer` and consumed by `morpheus-hubspot` currently has no correlation\nidentifier tying the two invocations together. Following one seller through the pipeline means\ngrepping two log groups on a shared business id.\n\n== The one alarm\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Property | Value\n\n| Name | `{sam-stack}-bedrock-daily-tokens-high`\n| Metric | `AWS/Bedrock` → `InputTokenCount`\n| Statistic | `Sum` over a 3600-second period\n| Threshold | `> 5,000,000` in one hour\n| Evaluation periods | 1\n| Missing data | `notBreaching`\n| Action | SNS `arn:aws:sns:{aws-region}:{aws-account}:security-operational-issues`\n|===\n\nIt is a cost guard, not a health check: it fires when something is consuming far more Bedrock\ninput tokens than normal — a runaway retry loop, an agent given an oversized context, a\nbackfill schedule re-enabled at a development cadence.\n\n[IMPORTANT]\n====\nThe SNS topic `security-operational-issues` is **not created by either stack**. It is assumed\nto exist. In a fresh account, the alarm deploys, transitions to `ALARM`, and its action fails\nsilently.\n====\n\n`TreatMissingData: notBreaching` is right for this metric — no Bedrock invocations means no\ndata points, and quiet is not a problem here.\n\n== Application Insights\n\n`ApplicationResourceGroup` groups the SAM stack's resources, and\n`ApplicationInsightsMonitoring` enables `AutoConfigurationEnabled: true` over it. AWS then\nprovisions its own default Lambda and SQS monitors and a dashboard.\n\nBecause it is auto-configured, what it watches is not declared anywhere in this repository, and\nit changes as AWS updates its defaults. Treat it as a convenience layer rather than as the\nmonitoring plan.\n\n== Gaps\n\nOrdered by how likely each is to matter:\n\n. **Sentry disabled.** Setting a real `SENTRY_DSN` is the single highest-value change\n  available — it converts silent failures into alerts.\n. **No DLQ alarms.** All three dead-letter queues can fill without notifying anyone. An alarm\n  on `ApproximateNumberOfMessagesVisible > 0` for each is straightforward.\n. **No queue-depth or message-age alarms.** With `enrichment-scorer` currently untriggered,\n  `EnrichmentPipelineQueue` grows until messages expire at four days, and nothing reports it.\n  See xref:queues.adoc[Queues and dead-letter queues].\n. **No Lambda error-rate or throttle alarms.** `ReservedConcurrentExecutions` makes throttling\n  a real possibility on `phone-agent`, which is capped at 2.\n. **No log retention on Lambda groups.** Unbounded storage growth.\n. **No schedule-failure signal.** A schedule whose `detail-type` no longer matches a registry\n  entry fires successfully into a handler that does not exist. The observable symptom is a\n  watermark in `configs` that stops advancing — see\n  xref:data:configs-table.adoc[Runtime state in `configs`].\n\n== Debugging without alarms\n\nGiven the above, the practical order when something looks wrong:\n\n. Check the relevant DLQ depth. A message there carries the failing payload.\n. Check `updatedAt` on the relevant `configs` watermark. A stale one names the schedule that\n  stopped completing.\n. Check `ApproximateAgeOfOldestMessage` on the main queue. A growing age means the consumer is\n  disabled, throttled, or erroring.\n. Only then read logs, filtered on the business id you already have from steps 1–3.\n\nThe runbooks are in xref:ROOT:operations.adoc[Operations and runbooks].\n"},{"component":"morpheus","module":"plans","path":"plans:index.adoc","title":"Roadmap and plans","description":"Forward-looking design documents for the agent estate and the BDR motion — intent, not implementation.","url":"https://docx.payability.com/morpheus/plans/index.html","navOrder":75,"source":"= Roadmap and plans\n:description: Forward-looking design documents for the agent estate and the BDR motion — intent, not implementation.\n\n[IMPORTANT]\n====\nEverything in this module is a **plan**. These pages describe systems that are proposed,\npartially built, or superseded. They are not a description of what runs today.\n\nFor what is actually deployed, read xref:ROOT:architecture.adoc[System architecture],\nxref:business:index.adoc[Business logic], and xref:lambdas:index.adoc[Lambda functions]. Where\na plan and a reference page disagree, the reference page is right.\n====\n\nThese documents were written to think through a channel before building it. They are kept\nbecause the reasoning is still useful — the segmentation logic, the message sequencing, the\nhandoff rules — even where the implementation went a different way.\n\n== Documents\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Plan | Status | Contents\n\n| xref:architecture.adoc[Target system architecture]\n| Partly built\n| The intended end-state: a multi-agent estate across email, voice, LinkedIn, and content,\n  coordinated over shared contact state.\n\n| xref:enrichment-agent.adoc[Enrichment agent]\n| **Built**\n| Discovery, contact enrichment, and scoring. Shipped as\n  xref:lambdas:enrichment-scraper.adoc[enrichment-scraper] and\n  xref:lambdas:enrichment-scorer.adoc[enrichment-scorer].\n\n| xref:email-agent.adoc[Email agent]\n| Partly built\n| Sequence generation, sending, and reply handling. Only reply *classification* shipped — see\n  xref:lambdas:email-agent.adoc[email-agent].\n\n| xref:voice-agent.adoc[Voice agent]\n| Not built as designed\n| An autonomous outbound caller on Retell AI. The deployed system instead supports human reps\n  through xref:business:call-lists.adoc[daily call lists] and a Five9 dialer.\n\n| xref:linkedin-agent.adoc[LinkedIn agent]\n| Not built\n| Connection requests, InMail, and engagement-based sequencing.\n\n| xref:content-agent.adoc[Content agent]\n| Not built\n| Inbound content generation and distribution.\n\n| xref:paid-ads.adoc[Paid ads]\n| Not built\n| Paid acquisition alongside outbound.\n\n| xref:re-engagement-reporting.adoc[Re-engagement reporting]\n| Not built\n| A catalogue of reports to measure whether the five re-engagement segments produce calls,\n  connections, and pipeline — plus the enrolment-log gap that blocks most of them.\n\n| xref:bdr-playbook.adoc[BDR playbook]\n| Operational\n| How three BDRs work a Five9 parallel dialer. Process documentation rather than a system\n  design, and the closest of these to current reality.\n|===\n\n== What shipped, and what changed on the way\n\nThree differences between these plans and the deployed system are worth stating plainly,\nbecause they explain most of the divergence.\n\nThe voice agent became a human enablement system::\nThe plan describes an AI agent making outbound calls autonomously. What was built assumes a\nhuman on the phone and spends its effort on deciding *who* they should call and *why* —\nBigQuery segmentation, priority scoring, round-robin assignment, task creation, and escalation.\nThe AI in the deployed calling path is confined to enrichment, scoring, and line intelligence.\n\nThe email agent shrank to a classifier::\nThe plan covers generation, sending, warm-up, and reply handling. The deployed\nxref:lambdas:email-agent.adoc[email-agent] does one thing: classify an inbound reply into an\n`EmailTargetCategory` using Bedrock structured output. Sending is handled by the web\napplication; this repository only reacts to what comes back.\n\nThe enrichment agent shipped close to plan::\nIt is the one design that survived largely intact — SmartScout discovery into a FIFO queue,\nClaude with Tavily and HubSpot MCP tools for contact enrichment, then a second pass for the\nxref:business:payout-pain-score.adoc[payout-pain score].\n\n== Using these pages\n\nRead them for intent and for the domain reasoning, not for interfaces. Payload shapes, model\nids, table names, and function names in these documents are frequently out of date — the\nauthoritative versions are in xref:events:catalog.adoc[Event catalog],\nxref:libs:morpheus-llm.adoc[morpheus-llm], and xref:data:index.adoc[Data].\n\nWhen a plan is implemented, the durable content belongs in `business`, `lambdas`, and `events`.\nLeave the plan in place, and mark what changed rather than editing the plan to match the\noutcome — the gap between the two is often the most informative thing on the page.\n"},{"component":"morpheus","module":"plans","path":"plans:architecture.adoc","title":"System Architecture","description":"Planned target architecture for the multi-agent outbound estate. Design intent, not deployed reality.","url":"https://docx.payability.com/morpheus/plans/architecture.html","navOrder":76,"source":"= System Architecture\n:description: Planned target architecture for the multi-agent outbound estate. Design intent, not deployed reality.\n\n== Overview\n\nThe system operates as a hub-and-spoke model with HubSpot as the central hub and five AI agents as spokes. Every agent reads from HubSpot (contact data, lifecycle stage, engagement history) and writes back to HubSpot (activity logs, score updates, stage transitions). The BDR team interacts with the system through HubSpot + Five9.\n\n== Data Flow\n\n[mermaid]\n....\nflowchart TD\n    subgraph sources [\"Marketplace Data Sources\"]\n        SmartScout[\"SmartScout API\\n(Amazon sellers)\"]\n        Walmart[\"Walmart Catalog\"]\n    end\n\n    subgraph enrichment [\"Enrichment Pipeline\"]\n        EA[\"Enrichment Agent\\n(AgentCore Runtime)\"]\n        Scoring[\"Lead Scoring Engine\\npayout_pain_score 0–100\"]\n    end\n\n    HubSpot[(\"HubSpot CRM\\ncentral hub\")]\n\n    subgraph outreach [\"Outreach Agents (AgentCore Runtime)\"]\n        EmailA[\"Email Agent\\nSES sequences\"]\n        VoiceA[\"Voice Agent\\nRetell AI calls\"]\n        LinkedInA[\"LinkedIn Agent\\nconnection + DMs\"]\n    end\n\n    subgraph channels [\"Seller Touch Points\"]\n        Inbox[\"Seller Inbox\"]\n        Phone[\"Seller Phone\"]\n        LinkedIn[\"Seller LinkedIn\"]\n    end\n\n    subgraph outcomes [\"Engagement Outcomes\"]\n        Reply[\"Reply / Click\"]\n        Transfer[\"Transfer / Callback\"]\n        Accept[\"Accept / Reply\"]\n    end\n\n    Five9[\"Five9\\nBDR Queue\"]\n    Demo[\"Demo Booked\\n→ Deal Created\"]\n\n    sources --> EA\n    EA --> HubSpot\n    HubSpot --> Scoring\n    Scoring -->|\"score > 40 → enroll\"| EmailA\n    Scoring -->|\"score > 70 → call first\"| VoiceA\n    Scoring --> LinkedInA\n\n    EmailA --> Inbox --> Reply\n    VoiceA --> Phone --> Transfer\n    LinkedInA --> LinkedIn --> Accept\n\n    Reply & Transfer & Accept --> HubSpot\n    HubSpot --> Five9 --> Demo\n....\n\n== HubSpot Configuration\n\n=== Custom Properties (Contact Level)\n* `marketplace` — enum: amazon, walmart, both\n* `estimated_annual_revenue` — number\n* `seller_storefront_url` — string\n* `primary_category` — string (top product category)\n* `seller_since` — date\n* `payout_pain_score` — number (0–100, calculated by Enrichment Agent)\n* `trigger_event` — enum: scaling_ads, payment_hold, seasonal_prep, new_launch, expanding_marketplace, none\n* `ai_enrichment_date` — date (last enrichment run)\n* `ai_email_sequence` — enum: cash_flow_calculator, seasonal_trigger, payment_hold_recovery\n* `ai_call_status` — enum: not_called, attempted, connected, transferred, callback_scheduled, not_interested\n* `ai_call_summary` — text (Retell AI conversation summary)\n* `source_channel` — enum: email, voice, linkedin, paid_ad, organic_content, referral\n\n=== Lifecycle Stages\n. `raw_lead` — Scraped/imported, not yet enriched\n. `enriched` — Enrichment Agent has scored and validated\n. `in_sequence` — Active in email/voice/LinkedIn outreach\n. `engaged` — Replied to email, answered call, accepted LinkedIn connection\n. `qualified` — AI has confirmed fit (revenue range, marketplace, interest)\n. `bdr_handoff` — Transferred to BDR queue in Five9\n. `demo_scheduled` — Demo booked\n. `opportunity` — In active sales process\n. `customer` — Signed up\n. `disqualified` — Wrong fit, not interested, do not contact\n\n=== HubSpot Workflows\n* *Auto-enroll in email sequence:* When lifecycle stage moves to `enriched` and `payout_pain_score` > 40\n* *Trigger voice agent:* When email sequence completes with no reply, or when `payout_pain_score` > 70 (high-priority, call first)\n* *BDR alert:* When AI voice agent completes a warm transfer or when email reply is classified as \"interested\"\n* *Multi-channel escalation:* email (no reply after sequence) → voice (no connect after 3 attempts) → LinkedIn (connection request)\n* *Re-engagement:* Contacts in `engaged` stage with no activity for 30 days re-enter a nurture sequence\n* *Reporting:* Daily digest of pipeline metrics to team Slack/email\n\n== AWS Architecture\n\n=== Runtime Philosophy: AgentCore-First\n\nThe five AI agents are deployed on *Amazon Bedrock AgentCore Runtime* — not as Lambda functions. This is the foundational architectural decision.\n\nLambda functions were considered first but fall short for agent workloads in several ways:\n* *15-minute hard timeout* — agents like the Enrichment Agent or LinkedIn Agent need to run for hours, not minutes\n* *No built-in session management* — tracking multi-turn context, sequence position, and cooldowns requires manually wiring DynamoDB state in every function\n* *No native memory* — cross-session persistence (e.g., \"what emails have we sent to this contact?\") has to be custom-built\n* *No built-in credential management* for outbound third-party services (LinkedIn, HubSpot, Retell)\n* *No tracing at the agent reasoning level* — CloudWatch shows function execution but not why the agent made a decision\n* *No multi-agent coordination protocol* — agent-to-agent handoffs require manual SQS/Step Functions wiring\n\nAgentCore Runtime solves all of these natively:\n* Runs agents in isolated microVMs with up to *8-hour session lifetime*\n* *True session isolation* — CPU, memory, and filesystem isolated per session\n* *Versioned deployments* with stable endpoint ARNs and zero-downtime rollouts\n* Supports any framework: LangGraph, Strands, CrewAI, LlamaIndex\n* Supports *A2A (Agent-to-Agent) protocol* for direct inter-agent communication\n* Supports *MCP* so Lambda-backed tools are accessible to agents without custom plumbing\n* Native *OpenTelemetry observability* — trace every reasoning step, not just function duration\n\n*Lambda's role in this architecture is the tool layer* — short, stateless utility functions that agents call as tools via AgentCore Gateway. Lambda is the right fit for: webhook receivers, SES sends, HubSpot CRUD, Retell API calls, email verification, and EventBridge trigger dispatchers. Lambda is not the right fit for running the agents themselves.\n\n'''\n\n=== AgentCore Runtime: The 5 AI Agents\n\nEach agent is a containerized Python application deployed to an AgentCore Runtime endpoint. The agent's reasoning loop (LLM calls, tool selection, memory reads/writes) runs inside AgentCore. Tools are invoked via AgentCore Gateway → Lambda.\n\n[mermaid]\n....\nflowchart LR\n    subgraph triggers [\"Triggers\"]\n        EB[\"EventBridge\\nschedules\"]\n        HSwh[\"HubSpot Webhook\"]\n        Vercel[\"Vercel Webhook\"]\n    end\n\n    subgraph agentcore [\"Bedrock AgentCore Runtime\"]\n        EP[\"AgentCore Runtime\\nEndpoint\"]\n\n        subgraph services [\"AgentCore Services\"]\n            Mem[(\"AgentCore Memory\\nsession context\\ncross-agent state\")]\n            Browser[\"AgentCore Browser\\nNova Act / Playwright\\nCDP over WebSocket\"]\n            Gateway[\"AgentCore Gateway\\nLambda-backed MCP tools\"]\n        end\n\n        EP <--> Mem & Browser & Gateway\n    end\n\n    subgraph tools [\"Lambda Tool Functions\"]\n        HSTool[\"tool-hubspot-read/write\"]\n        SESTool[\"tool-ses-send\"]\n        RetellTool[\"tool-retell-call\"]\n        ScoutTool[\"tool-smartscout\"]\n        WalmartTool[\"tool-walmart-scraper\"]\n    end\n\n    triggers --> EP\n    Gateway --> HSTool & SESTool & RetellTool & ScoutTool & WalmartTool\n....\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| Agent | AgentCore Runtime Name | Framework | Schedule / Trigger\n\n| Enrichment Agent | `marketplace-enrichment-agent` | Strands or LangGraph | EventBridge daily 2am ET\n| Email Agent | `marketplace-email-agent` | Strands or LangGraph | EventBridge every 15 min + SES inbound webhook\n| Voice Agent Orchestrator | `marketplace-voice-agent` | Strands | EventBridge every 30 min (Tue–Thu) + Retell webhook\n| LinkedIn Agent | `marketplace-linkedin-agent` | LangGraph | EventBridge every 2 hours\n| Content Agent | `marketplace-content-agent` | Strands or LangGraph | EventBridge weekly Monday 6am ET\n|===\n\nEach AgentCore Runtime:\n* Is deployed as a Docker container (Python 3.12, `bedrock-agentcore` SDK + framework deps)\n* Has a `DEFAULT` endpoint ARN that EventBridge/webhooks invoke via `InvokeAgentRuntime`\n* Creates a new versioned snapshot on every deployment (instant rollback available)\n* Runs each scheduled invocation as an isolated microVM session\n\n'''\n\n=== AgentCore Services Used\n\n==== AgentCore Memory\nReplaces the manual DynamoDB state-tracking pattern for agent context. Each agent has both short-term and long-term memory stores:\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| Memory Store | Scope | Used By | What It Holds\n\n| `enrichment-context` | Long-term | Enrichment Agent | Which sellers scraped, enrichment timestamps, score history\n| `email-sequences` | Long-term | Email Agent | Per-contact sequence position, send history, reply history, suppression state\n| `voice-calls` | Long-term | Voice Agent | Call attempt history, callback schedules, outcome per contact\n| `linkedin-sequences` | Long-term | LinkedIn Agent | Per-profile daily action counts, per-contact sequence step, acceptance status\n| `content-calendar` | Long-term | Content Agent | Published articles, keyword coverage map, scheduled drafts\n| `cross-agent-leads` | Long-term, *shared* | All 5 agents | Canonical lead status readable by any agent — prevents duplicate outreach across channels\n|===\n\nShort-term memory (active session context) is managed automatically by AgentCore per `runtimeSessionId`.\n\n==== AgentCore Browser\nThe browser infrastructure for tasks that genuinely require a browser — primarily LinkedIn enrichment, seller domain enrichment, and LinkedIn outreach actions. Both Nova Act (primary) and Playwright (fallback) connect to AgentCore Browser over a secure WebSocket/CDP connection. There is no self-managed headless Chrome, no Lambda Chromium layer, no ECS Fargate browser farm.\n\n*Not used for Walmart catalog scraping.* Walmart seller pages are static HTML and are scraped with httpx in Lambda (pennies per crawl vs ~$0.02/page with a browser agent). AgentCore Browser is reserved for tasks where the page is JS-heavy or the structure is unpredictable — conditions that don't apply to a structured catalog crawl. See the Walmart scraping tier table below.\n\n*How it works:*\n. Agent opens an AgentCore Browser session\n. AgentCore Browser provisions an isolated, containerized cloud browser\n. Nova Act (primary) or Playwright (fallback) connects to it via WebSocket/CDP\n. Browser session runs up to 8 hours; recordings stored in S3 via AgentCore\n\n*Browser execution pattern (Nova Act primary → Playwright fallback):*\nFor tasks routed to AgentCore Browser, Nova Act is tried first. If Nova Act escalates (CAPTCHA, unknown UI state, timeout), the agent retries with Playwright on the same (or fresh) AgentCore Browser session.\n\n[mermaid]\n....\nsequenceDiagram\n    participant A as Agent\n    participant B as AgentCore Browser\n    participant NA as Nova Act (primary)\n    participant PW as Playwright (fallback)\n    participant S3 as S3 (recordings)\n\n    A->>B: start browser session\n    B->>NA: connect via CDP\n    NA->>B: execute task\n\n    alt success\n        B-->>A: task result\n    else CAPTCHA / unknown UI / timeout\n        B->>PW: connect via CDP (same session)\n        PW->>B: retry same task\n        B-->>A: task result\n    end\n\n    B->>S3: session recording (DOM + actions + network)\n....\n\n*Walmart seller scraping — 3-tier cost-optimized approach:*\nNova Act at $4.75/hour makes it cost-prohibitive for high-volume catalog crawls. A 10,000-page initial pass at 15 seconds/page = ~$200 in browser compute. Walmart pages are structured HTML, so AI reasoning adds no value at that volume.\n\n[cols=\"1,1,1,1\",options=\"header\"]\n|===\n| Tier | Method | Cost | When Used\n\n| 1 (primary) | `tool-walmart-scraper` Lambda — httpx + BeautifulSoup | ~$0.000002/page | All pages; standard static HTML\n| 2 (secondary) | Playwright → AgentCore Browser (CDP) | Low | Tier 1 returns `needs_browser` (JS-rendered page)\n| 3 (last resort) | Nova Act → AgentCore Browser | ~$0.02/page | Tier 2 fails; unknown layout or CAPTCHA\n|===\n\nLog `scrape_executor: httpx | playwright | nova_act` per page to AgentCore Memory. A spike in Tier 2/3 activation rate signals a Walmart HTML change that warrants updating the Tier 1 selectors.\n\nKey AgentCore Browser features used:\n* *Persistent browser profiles* — LinkedIn session cookies stored as a persistent profile per BDR account; no manual Secrets Manager cookie rotation on every task\n* *Proxy routing* — built-in proxy configuration for Playwright fallback path; no external proxy service needed\n* *Live View* — real-time session monitoring via AgentCore console for debugging\n* *Session recording* — DOM changes, actions, and network events logged to S3; used for compliance and debugging\n\n==== AgentCore Gateway\nExposes Lambda-backed tool functions to agents via MCP-compatible endpoints. Agents call tools using natural language tool names; Gateway handles routing, auth, and rate limiting.\n\nLambda functions wrapped as Gateway tools:\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Tool Name (MCP) | Lambda Function | Purpose\n\n| `hubspot_create_contact` | `tool-hubspot-write` | Create or update HubSpot contact\n| `hubspot_get_contact` | `tool-hubspot-read` | Read contact properties and lifecycle stage\n| `hubspot_create_task` | `tool-hubspot-write` | Create BDR task or alert in HubSpot\n| `hubspot_update_stage` | `tool-hubspot-write` | Move contact to new lifecycle stage\n| `ses_send_email` | `tool-ses-send` | Send email via SES from selected mailbox\n| `ses_verify_email` | `tool-ses-verify` | MX + SMTP email verification\n| `retell_trigger_call` | `tool-retell-call` | Trigger Retell AI outbound call\n| `smartscout_query` | `tool-smartscout` | Query SmartScout API for seller data\n| `walmart_scrape_seller` | `tool-walmart-scraper` | httpx + BeautifulSoup HTML scrape of a Walmart seller page (Tier 1); returns `needs_browser` flag if JS rendering is required\n| `dynamo_check_suppression` | `tool-suppression` | Check email/voice suppression lists\n|===\n\nGateway wraps each Lambda with a stable MCP endpoint. Agents never call Lambda ARNs directly.\n\n==== AgentCore Identity\nManages all outbound credentials for agents. Replaces manual Secrets Manager lookups scattered across Lambda functions.\n\n* *LinkedIn sessions* — stored as persistent browser profiles in AgentCore Browser (user-delegated per BDR account)\n* *HubSpot private app token* — autonomous credential; retrieved by Gateway tools at invocation\n* *Retell AI API key* — autonomous credential for Voice Agent\n* *SmartScout API key* — autonomous credential for Enrichment Agent\n* *SES SMTP credentials* — autonomous per-mailbox credentials for Email Agent\n* *Anthropic Claude API key* — autonomous credential; shared across all agents\n\n==== AgentCore Observability\nReplaces Lambda-level CloudWatch logs with agent-level reasoning traces.\n\n* Every agent invocation produces an OpenTelemetry trace: each reasoning step, tool call, memory read/write, and browser action is a span\n* Traces visible in AgentCore console and forwarded to CloudWatch for unified dashboards\n* Per-agent dashboards: tool call success rates, memory hit/miss rates, browser task escalation rate, session duration\n* Alert thresholds: tool error rate > 5%, browser escalation rate > 10%, agent session timeout\n\n==== AgentCore Policy\nGuardrails on tool access to prevent agents from taking out-of-bounds actions:\n\n* Email Agent: cannot invoke `retell_trigger_call` or `hubspot_update_stage` to `bdr_handoff` — only the Voice Agent can do that\n* LinkedIn Agent: daily action count caps enforced at Policy layer (not just DynamoDB counters)\n* Content Agent: cannot write to HubSpot contacts — read-only access to `hubspot_get_contact`\n* All agents: cannot update contacts in `customer` or `disqualified` stage\n\n'''\n\n=== Lambda Functions (Tool Layer)\n\nLambda's role is limited to *short-lived, stateless utility executions* invoked via AgentCore Gateway or directly by webhooks/EventBridge. Python 3.12 runtime. No agent reasoning logic lives in Lambda.\n\n*Tool functions (invoked via AgentCore Gateway):*\n* `tool-hubspot-read` — Read-only HubSpot contact/deal queries\n* `tool-hubspot-write` — Create/update contacts, deals, tasks, lifecycle stages\n* `tool-ses-send` — Send email via SES with mailbox rotation logic\n* `tool-ses-verify` — MX + SMTP email address verification\n* `tool-retell-call` — Trigger Retell AI outbound call via Retell API\n* `tool-smartscout` — Query SmartScout API for Amazon seller data\n* `tool-walmart-scraper` — Tier 1 Walmart seller page scraper; httpx + BeautifulSoup; returns structured JSON or `needs_browser` flag if page requires JS rendering. Costs ~$0.000002/page vs ~$0.02/page for browser-based approaches.\n* `tool-suppression` — Read/write DynamoDB suppression and rate-limit tables\n\n*Webhook receivers (invoked directly, not via Gateway):*\n* `webhook-ses-inbound` — Receives inbound email via SES receipt rules → SNS → Lambda; pushes to Email Agent's AgentCore Runtime endpoint\n* `webhook-retell` — Receives Retell AI call completion events from Vercel → forwards to Voice Agent's AgentCore Runtime endpoint\n* `webhook-hubspot` — Receives HubSpot workflow triggers → dispatches to appropriate agent endpoint\n\n*EventBridge dispatchers (thin triggers only):*\n* `dispatch-enrichment` — Invoked by EventBridge; calls `InvokeAgentRuntime` on the Enrichment Agent endpoint\n* `dispatch-email-sender` — Invoked by EventBridge every 15 min; calls Email Agent endpoint with send trigger\n* `dispatch-voice` — Invoked by EventBridge every 30 min; calls Voice Agent endpoint\n* `dispatch-linkedin` — Invoked by EventBridge every 2 hours; calls LinkedIn Agent endpoint\n* `dispatch-content` — Invoked by EventBridge weekly; calls Content Agent endpoint\n\nThese dispatcher Lambdas are intentionally trivial — they exist only because EventBridge cannot invoke AgentCore Runtime endpoints directly. Each is under 20 lines of code.\n\n'''\n\n=== Data Stores\n\n* *AgentCore Memory* — Primary store for all agent context, sequence state, and cross-agent shared lead status (see AgentCore Services section above)\n* *DynamoDB* — Used for operational data that AgentCore Memory is not suited for: per-mailbox email send quotas (checked at high frequency with atomic counters), SES bounce/complaint suppression lists, raw scrape deduplication keys, call attempt logs with timestamps\n* *S3* — Generated content assets, email templates, call recordings (from Retell + AgentCore Browser), prospect list exports, content drafts pending review\n* *SES* — Transactional and cold outbound email (15 mailboxes across 5 domains; see email-agent.md)\n\n=== EventBridge Schedules\nAll schedules invoke thin Lambda dispatcher functions which call `InvokeAgentRuntime` on the appropriate AgentCore endpoint.\n\n* Enrichment Agent: Daily at 2am ET\n* Email Agent (send trigger): Every 15 minutes, 8am–6pm ET Mon–Fri\n* Voice Agent: Every 30 minutes, 9am–5pm ET Tue–Thu\n* LinkedIn Agent: Every 2 hours, 7am–9pm ET Mon–Fri\n* Content Agent: Weekly on Monday at 6am ET\n* Lead scoring refresh: Every 6 hours (Enrichment Agent endpoint, scoring-only mode)\n* HubSpot sync check: Every 5 minutes (lightweight Lambda, no AgentCore session needed)\n\n== Vercel Deployment\n\n=== Frontend Applications\n* *Marketing site / Landing pages:* Next.js app with dynamic pages for each ad campaign. Server-side rendering for SEO. Deploy on Vercel with automatic previews for each PR.\n* *Cash Flow Calculator:* Interactive React component embedded in landing pages. Takes monthly revenue as input, calculates locked capital, shows daily payout benefit. Captures email on results page → pushes to HubSpot.\n* *Demo Booking Page:* Embedded Calendly or custom scheduling component connected to BDR calendars in HubSpot.\n\n=== API Routes (Vercel Serverless Functions)\n* `/api/webhook/hubspot` — Receives HubSpot workflow webhook triggers, dispatches to appropriate AWS agent\n* `/api/webhook/retell` — Receives Retell AI call completion events, updates HubSpot\n* `/api/webhook/ses` — Receives SES email events (bounces, complaints, deliveries)\n* `/api/calculator/submit` — Handles cash flow calculator submissions, creates HubSpot contact\n* `/api/content/preview` — Previews AI-generated content before publishing\n\n=== Edge Functions\n* Geolocation-based content personalization (show relevant case studies by region)\n* A/B test routing for landing pages\n* Bot detection for ad traffic quality\n\n== Security & Compliance\n\n* *Credentials:* All outbound credentials (HubSpot token, Retell API key, Claude API key, SES credentials) managed by AgentCore Identity — never stored in Lambda environment variables or hardcoded in agent code.\n* *Session isolation:* Each AgentCore Runtime session runs in a dedicated microVM with isolated CPU, memory, and filesystem. No cross-session data leakage.\n* *Browser isolation:* Each AgentCore Browser session is ephemeral and isolated. Session memory is sanitized after termination.\n* *Tool-level access control:* AgentCore Policy guardrails enforce which tools each agent can access and under what conditions. Agents cannot exceed their defined scope.\n* *SES sending domains:* SPF, DKIM, and DMARC configured on all 5 sending domains.\n* *TCPA compliance:* Retell AI calls restricted to permitted hours via Voice Agent's scheduling logic and AgentCore Policy guardrails on the `retell_trigger_call` tool.\n* *PII handling:* All prospect data stored in HubSpot (source of truth). AgentCore Memory stores behavioral context (sequence position, call outcomes) — not raw PII. DynamoDB stores suppression keys (hashed emails, phone hashes) — not plaintext.\n* *Inbound auth:* AgentCore Runtime endpoints protected via AWS IAM (SigV4) — only dispatcher Lambdas and webhook handlers with correct IAM roles can invoke agent endpoints.\n\n== Monitoring & Alerting\n\n* *Agent reasoning traces:* AgentCore Observability provides step-level OpenTelemetry traces for every agent invocation — visible in AgentCore console and forwarded to CloudWatch. Alert if any agent tool error rate exceeds 5%.\n* *Browser automation:* AgentCore Browser metrics in CloudWatch — session success rate, Nova Act escalation rate (alert if >10% for any agent), Playwright fallback activation rate. Session recordings in S3 for debugging.\n* *Pipeline health:* Daily HubSpot report showing leads by lifecycle stage, conversion rates, and pipeline velocity.\n* *Email deliverability:* SES reputation dashboard. Alert if bounce rate exceeds 2% or complaint rate exceeds 0.1% — auto-pause affected mailbox via Email Agent policy.\n* *Voice agent:* Retell AI dashboard for call completion rates, average duration, transfer rates. Alert if completion rate drops below 50%.\n* *Lambda tool health:* CloudWatch alarms on tool Lambda error rates and duration. These are short utility functions — any error rate > 1% warrants investigation since agent quality degrades immediately when tools fail.\n* *Cost monitoring:* AWS Cost Explorer alerts if daily spend exceeds 150% of expected. AgentCore Runtime, AgentCore Browser, and Lambda tool invocations all contribute to cost — monitor separately.\n"},{"component":"morpheus","module":"plans","path":"plans:enrichment-agent.adoc","title":"Enrichment Agent","description":"Original design for seller discovery, contact enrichment, and scoring. Shipped largely as planned.","url":"https://docx.payability.com/morpheus/plans/enrichment-agent.html","navOrder":77,"source":"= Enrichment Agent\n:description: Original design for seller discovery, contact enrichment, and scoring. Shipped largely as planned.\n\n== Purpose\n\nThe Enrichment Agent is the foundation of the entire system. It continuously discovers new marketplace sellers, enriches them with business data, scores their likelihood of needing daily payouts, and pushes qualified leads into HubSpot for the other agents to work.\n\n== Data Sources\n\n=== Marketplace Data\n* *SmartScout API* (or equivalent): Identify Amazon sellers by estimated revenue range ($100K–$1M), category, and marketplace. Pull seller name, storefront URL, estimated monthly revenue, number of ASINs, primary category, and seller tenure.\n* *Walmart Marketplace scraping:* Walmart's seller ecosystem has fewer third-party tools. Build a custom scraper that identifies active Walmart sellers from product listings, extracts seller profiles, and estimates revenue from review velocity and product count.\n* *Amazon SP-API (if available):* For deeper data on sellers who have public storefronts.\n\n=== Contact Enrichment\nOnce seller business data is collected, enrich with contact information:\n* *LinkedIn scraping (Nova Act):* Match seller business name to LinkedIn company pages using Nova Act via AgentCore Browser, then identify the owner/founder. Nova Act handles LinkedIn's dynamic layout and varied profile structures without brittle CSS selectors. Extract name, title, and LinkedIn URL. Falls back to Playwright via AgentCore Browser if Nova Act escalates.\n* *Email discovery:* Use a combination of pattern matching (first@company.com, first.last@company.com), MX record lookups, and SMTP verification to find valid email addresses. Build this as a custom module rather than relying on third-party enrichment tools.\n* *Domain discovery (Nova Act):* Many Amazon sellers have their own websites. Nova Act navigates the seller's domain, checks About and Contact pages, and extracts team member names, emails, and phone numbers — handling varied site layouts reliably without custom selectors per site. Falls back to Playwright via AgentCore Browser if Nova Act escalates.\n* *Phone number discovery:* Extracted by the Nova Act domain agent from contact pages and About pages. Also checks LinkedIn profiles when available. Critical for the voice agent and BDR outreach.\n\n== Lead Scoring Model\n\nThe Enrichment Agent calculates a `payout_pain_score` (0–100) based on weighted signals:\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Signal | Weight | Logic\n\n| Estimated monthly revenue | 20 | Higher revenue = more cash locked up. Sweet spot: $10K–$80K/month.\n| Revenue growth trend | 15 | Growing sellers have the most acute cash flow pressure.\n| Number of ASINs/products | 10 | More products = more inventory to fund = more cash flow pressure.\n| Primary category | 10 | Categories with high inventory costs (electronics, supplements, home goods) score higher.\n| Seller tenure | 10 | 1–3 years scores highest (established enough to have real cash flow, not yet large enough for institutional financing).\n| Multi-marketplace presence | 10 | Selling on both Amazon and Walmart doubles the payment delay.\n| Seasonal timing | 15 | Score increases 60–90 days before Q4, Prime Day, and other peak seasons.\n| Trigger event detected | 10 | If we detect signals of payment holds, new product launches, or scaling activity.\n|===\n\n=== Score Thresholds\n* *70–100:* High priority. Route to Voice Agent immediately (call before email). Also enters email sequence.\n* *40–69:* Medium priority. Enters email sequence. Voice Agent calls after email sequence if no reply.\n* *20–39:* Low priority. Email sequence only. Re-score monthly.\n* *0–19:* Do not contact. Monitor and re-score quarterly.\n\n== Implementation\n\n=== Lambda: `enrichment-scraper`\n\n*Trigger:* EventBridge daily schedule (2am ET)\n*Input:* None — pulls from configured data sources\n*Output:* Raw seller records pushed to SQS queue for scoring\n\n*Steps:*\n. Query SmartScout API for sellers matching ICP criteria\n. Scrape Walmart marketplace for new seller profiles — see Walmart Scraping Strategy below\n. Deduplicate against existing HubSpot contacts, matching on storefront URL or business name\n. Push new raw records to SQS queue\n\n=== Lambda: `enrichment-scorer`\n\n*Trigger:* SQS queue (batch size: 10)\n*Input:* Raw seller record\n*Output:* Enriched, scored contact pushed to HubSpot\n\n*Steps:*\n. Enrich with contact info — LinkedIn (Nova Act), email discovery, phone, and domain (Nova Act). See Contact Enrichment Strategy below.\n. Verify email address via MX + SMTP check\n. Calculate `payout_pain_score` using all enriched signals\n. Create or update HubSpot contact with all enriched fields\n. Set `lifecycle_stage` to `enriched`\n. If score ≥ 70, add to high-priority list for Voice Agent\n\n=== DynamoDB Table: `enrichment-state`\n* Tracks which sellers have been scraped, when, and their current enrichment status\n* Prevents duplicate scraping and rate-limit violations\n* Schema: `seller_id (PK) | marketplace | last_scraped | enrichment_status | hubspot_contact_id`\n\n'''\n\n== Walmart Scraping Strategy\n\nWalmart seller pages are largely server-rendered HTML with stable, known fields. At Nova Act pricing (~$0.02/page for a 15-second session), a 10,000-page initial catalog crawl would cost approximately $200 in browser compute alone. Direct HTTP requests cost pennies for the same crawl. The execution strategy is therefore tiered by cost and complexity:\n\n*Tier 1 — Direct HTTP (Primary):* The Lambda makes a direct HTTP GET request to the Walmart seller storefront URL with a realistic browser user-agent. If the response is successful and the HTML contains expected structure, it parses seller name, product count, rating, review count, category, and multi-location status. This handles the vast majority of pages.\n\nEscalates to Tier 2 when: HTTP status is not 200; the response body is suspiciously short, indicating JS-rendered content; or more than two required fields are absent from the parsed HTML.\n\n*Tier 2 — Playwright via AgentCore Browser (Secondary):* Playwright connects to an AgentCore Browser session via CDP and navigates to the storefront URL, waiting for full JS rendering before extracting the same fields. No self-managed Chrome or Lambda Chromium layer required.\n\nEscalates to Tier 3 when: known selectors no longer return results due to a major Walmart layout change, or a CAPTCHA appears.\n\n*Tier 3 — Nova Act via AgentCore Browser (Last Resort):* Nova Act receives a natural language instruction to navigate the storefront and extract all required seller fields as structured JSON. At ~$0.02/page, used only for the small fraction of pages that Tiers 1 and 2 cannot handle.\n\nThe Lambda logs `scrape_executor: direct_http | playwright | nova_act` per page to DynamoDB. A spike in Tier 2 or Tier 3 usage signals a Walmart HTML change that warrants updating the Tier 1 parsing logic.\n\n'''\n\n== Contact Enrichment Strategy\n\nFor LinkedIn and seller domain scraping, the page structures are varied and unpredictable — Nova Act's AI-driven browser automation is the right fit here. Volume is manageable (enriching 40–60% of 500–1,000 new sellers per week), so Nova Act costs are reasonable. Both tasks fall back to Playwright via AgentCore Browser if Nova Act escalates.\n\n*LinkedIn enrichment:* Nova Act navigates LinkedIn, searches for the seller's business name, locates the company page, identifies the owner or founder (by title: Owner, Founder, CEO, President), and extracts their name, title, and LinkedIn profile URL. Falls back to Playwright if Nova Act hits an identity verification challenge, task timeout, or ambiguous search results.\n\n*Domain enrichment:* Nova Act navigates to the seller's website and checks the About and Contact pages for email addresses, phone numbers, and team member names — handling varied layouts across thousands of different seller sites without per-site selector maintenance. Falls back to Playwright if Nova Act escalates or the page structure is too sparse to extract useful data.\n\nPlaywright fallback for both tasks connects to an AgentCore Browser session via CDP. LinkedIn sessions use persistent browser profiles managed by AgentCore Identity — no manual cookie rotation.\n\nThe Lambda logs `enrichment_executor: nova_act | playwright` per contact and per enrichment type (linkedin, domain) to DynamoDB for monitoring.\n\n'''\n\n== Refresh Cadence\n\n* *New seller discovery:* Daily\n* *Re-enrichment of existing contacts:* Monthly — re-scrape marketplace data, update revenue estimates, recalculate scores\n* *Score refresh based on timing signals:* Every 6 hours, to catch seasonal score boosts as Q4 approaches\n\n== Volume Expectations\n\n* Initial build: 5,000–10,000 sellers in first 30 days\n* Ongoing: 500–1,000 new sellers discovered per week\n* After enrichment and verification: expect 40–60% of raw sellers to have valid, usable contact info\n"},{"component":"morpheus","module":"plans","path":"plans:voice-agent.adoc","title":"Voice Agent (Retell AI)","description":"Proposed autonomous outbound caller on Retell AI. The deployed system supports human reps instead.","url":"https://docx.payability.com/morpheus/plans/voice-agent.html","navOrder":78,"source":"= Voice Agent (Retell AI)\n:description: Proposed autonomous outbound caller on Retell AI. The deployed system supports human reps instead.\n\n== Purpose\n\nThe Voice Agent handles outbound calling at scale using Retell AI's conversational AI platform. It calls marketplace sellers, delivers a concise pitch about daily payouts, qualifies interest, handles objections, and either books a demo directly or warm-transfers to a BDR on Five9. It also handles inbound calls from sellers who call back after receiving a voicemail or email.\n\n== Retell AI Configuration\n\n=== Agent Setup\n* *Voice:* Select a natural, confident, friendly voice. Male or female — test both. Avoid robotic-sounding options. Retell AI's voice cloning can create a custom voice that matches your brand.\n* *Language:* English (US)\n* *Latency mode:* Low latency for natural conversation flow\n* *Interruption handling:* Allow the seller to interrupt — the agent should stop speaking and listen. This is critical for natural conversation.\n* *Call recording:* Enable for all calls. Store recordings in S3 via webhook for compliance and training.\n* *Call summary:* Enable post-call summary generation. Push to HubSpot via webhook.\n\n=== Telephony\n* Provision phone numbers through Retell AI or bring your own numbers via SIP trunk.\n* Use local area code numbers matching the seller's region when possible (increases answer rates by 15–30%).\n* Caller ID should display your company name where supported.\n\n== Conversation Flow\n\n=== Outbound Call Script\n\nThe Retell AI agent follows this conversation flow. Design it as a state machine in Retell's conversation builder:\n\n*State 1: Opener (max 15 seconds)*\n----\n\"Hi, is this {first_name}? ... Great. This is [Agent Name] calling from [Company].\nI work with Amazon sellers — I'll be really quick, do you have 60 seconds?\"\n----\nIf no → \"No problem. When would be a better time to call back?\" → Schedule callback in HubSpot → End call.\nIf yes → State 2.\n\n*State 2: Hook (max 20 seconds)*\n----\n\"The reason I'm calling — most sellers we talk to have $20,000 to $50,000\nsitting in Amazon at any given time just waiting to be paid out. We help sellers\nget that money every day instead of waiting two weeks. Have you ever thought\nabout how much cash you have locked up in there right now?\"\n----\nThis is designed to trigger the \"aha moment.\" Wait for response → State 3.\n\n*State 3: Qualify (max 45 seconds)*\nListen to their response. The agent should:\n* If they're curious: Explain briefly how it works (\"You keep selling normally. We analyze your balance and deposit your earnings into your bank account every day. Or you can use our Seller Visa Card for instant access. It's not a loan — it's your own money, just faster.\")\n* If they ask about cost: \"It's a small percentage of your sales volume — typically 2 to 3 percent. Most sellers find it pays for itself because they can reinvest faster.\"\n* If they push back: Go to objection handling (State 4).\n* If they're interested: Go to close (State 5).\n\n*State 4: Objection Handling*\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Trigger Phrase | Response\n\n| \"Is this a loan?\" / \"sounds like debt\" | \"No, not at all. It's your own money — you've already made the sale. We just get it to you faster. No interest, no credit check, no repayment schedule.\"\n| \"What's the catch?\" / \"What's the fee?\" | \"The fee is typically 2 to 3 percent of your sales. For most sellers doing $30K or more a month, that's a few hundred dollars to free up tens of thousands in working capital. It usually pays for itself through faster inventory turns and fewer stockouts.\"\n| \"I'm fine waiting\" / \"I don't need this\" | \"Totally fair. Can I ask — when you're stocking up for a busy period, or when you want to launch a new product, does the timing of your Amazon payouts ever slow you down?\" (Re-engage with pain)\n| \"Never heard of this\" / \"Is this legit?\" | \"You're not alone, most sellers haven't heard of this yet. We work with over 100 sellers right now. I can connect you with one of our team members who can walk you through it and even introduce you to a seller in your niche. Would that be helpful?\"\n| \"I'm too busy right now\" | \"Completely understand. Can I send you a quick case study by email? If it looks interesting you can book a call when it's convenient. What's the best email?\" (Capture email → HubSpot → Email Agent sends case study)\n| \"How does it actually work?\" | \"It's simple — you connect your Amazon or Walmart account to our platform, we see your sales in real time, and we deposit your earnings into your bank account every day. No changes to how you sell, ship, or run your business. Takes about 10 minutes to set up.\"\n|===\n\nAfter handling objection → return to qualify or close.\n\n*State 5: Close*\n----\n\"It sounds like this could be a good fit. The best next step would be a quick\n15-minute call with one of our team — they can pull up your actual numbers and\nshow you exactly how much cash you'd free up. Do you have time [tomorrow/later\nthis week]?\"\n----\nIf yes → Transfer to BDR queue or book in calendar.\nIf \"send me info first\" → Capture email → HubSpot → Email Agent sends info → Schedule follow-up call.\n\n*State 6: Warm Transfer to BDR*\nWhen the seller is qualified and interested, the agent transfers to a live BDR:\n----\n\"Great. Let me connect you with [BDR Name] right now — they specialize in\nsetting up sellers in your category. One moment.\"\n----\nRetell AI initiates warm transfer to Five9 queue. The agent provides a whisper to the BDR with seller context (name, marketplace, category, estimated revenue, key interest signals).\n\n*State 7: Voicemail*\nIf the call goes to voicemail (detect after 4–5 rings or voicemail greeting):\n----\n\"Hi {first_name}, this is [Agent Name] from [Company]. I work with Amazon sellers\nand wanted to share something quick about getting paid daily on your sales instead\nof waiting two weeks. I'll follow up by email — or feel free to call me back at\n[callback number]. Thanks!\"\n----\nKeep under 20 seconds. Log voicemail in HubSpot. Trigger Email Agent to send follow-up email within 2 hours.\n\n== AWS Orchestration\n\n=== Runtime: AgentCore Runtime (`marketplace-voice-agent`)\n\nThe Voice Agent orchestrator runs as an AgentCore Runtime deployment. Retell AI handles the actual conversation; this agent manages call scheduling, TCPA compliance, and result processing. Lambda is used only as a thin webhook receiver and a scheduled dispatcher.\n\n*AgentCore services used by this agent:*\n* *AgentCore Runtime* — hosts the orchestration logic; handles both scheduled outbound runs and inbound Retell webhook events as separate sessions\n* *AgentCore Memory* (`voice-calls` store) — per-contact call attempt history, callback schedules, cooldown timestamps, daily call counts; replaces manual DynamoDB call tracking\n* *AgentCore Memory* (`cross-agent-leads` shared store) — reads contact score and email sequence status; writes call outcomes visible to LinkedIn Agent\n* *AgentCore Identity* — manages Retell AI API key, HubSpot token\n* *AgentCore Gateway* — exposes `retell_trigger_call`, `hubspot_get_contact`, `hubspot_update_stage`, `hubspot_create_task`, `dynamo_check_suppression` as MCP tools\n* *AgentCore Policy* — enforces TCPA calling window as a guardrail on `retell_trigger_call` tool; agent cannot trigger calls outside permitted hours regardless of reasoning\n\n*Triggers:*\n* Scheduled run: EventBridge every 30 min → `dispatch-voice` Lambda → `InvokeAgentRuntime`\n* Call result: Retell webhook → Vercel `/api/webhook/retell` → `webhook-retell` Lambda → `InvokeAgentRuntime` with call outcome payload\n\n=== AgentCore Runtime: `voice-orchestration-session`\n----\nTrigger: EventBridge every 30 minutes, 9am–5pm ET Tue–Thu\n\nSteps:\n1. Read from AgentCore Memory (cross-agent-leads + voice-calls):\n   - Score >= 70 AND not yet called (high priority)\n   - Email sequence completed, no reply, score >= 40 (escalation)\n   - Callback scheduled for today's window\n2. Read daily call counts and cooldowns from AgentCore Memory (voice-calls store)\n3. For each eligible contact:\n   a. Verify TCPA compliance — AgentCore Policy guardrail on retell_trigger_call\n      blocks calls outside 8am–9pm seller's local timezone automatically\n   b. Call `retell_trigger_call` tool to trigger Retell AI outbound call\n   c. Write call attempt to AgentCore Memory: timestamp, attempt count, session ID\n   d. Call `hubspot_update_stage` tool to set ai_call_status = \"attempted\"\n\nAgentCore Runtime: `voice-result-session` (separate invocation per call result)\nTrigger: Retell webhook event → webhook-retell Lambda → InvokeAgentRuntime\n\nSteps:\n1. Parse call outcome from Retell payload (summary, duration, disposition, transcript)\n2. Match to contact via AgentCore Memory (voice-calls store) using Retell session ID\n3. Process outcome:\n   - warm_transfer: call `hubspot_create_task` (BDR alert), `hubspot_update_stage`\n     (→ bdr_handoff), write to cross-agent-leads memory\n   - callback_requested: update AgentCore Memory with next callback window\n   - not_interested: call `hubspot_update_stage` (→ disqualified),\n     `dynamo_check_suppression` (add to voice suppression)\n   - voicemail: call `hubspot_update_stage` (ai_call_status = voicemail);\n     write to cross-agent-leads memory to trigger Email Agent follow-up within 2 hours\n   - no_answer: increment attempt count in AgentCore Memory; after 3 attempts\n     write \"voice_exhausted\" flag to cross-agent-leads memory for LinkedIn Agent\n4. Write full call summary (Retell transcript + outcome) to AgentCore Memory\n5. Call `hubspot_update_stage` tool with ai_call_summary and updated ai_call_status\n----\n\n=== Retell AI Webhook Events\nConfigure Retell to send webhooks to your Vercel endpoint (`/api/webhook/retell`) for:\n* `call.completed` — Call finished, includes transcript, summary, duration, outcome\n* `call.transferred` — Warm transfer initiated to Five9\n* `call.failed` — Call could not connect (invalid number, network error)\n\n=== Call Scheduling Logic\n* *Primary window:* Tuesday–Thursday, 10am–12pm and 2pm–4pm in seller's timezone\n* *Secondary window:* Monday and Friday, 10am–3pm (lower priority)\n* *Max attempts per contact:* 3 outbound calls over 10 days\n* *Cooldown between attempts:* Minimum 3 days\n* *After 3 no-connects:* Escalate to LinkedIn Agent, mark as \"voice_exhausted\"\n\n== Integration with Five9\n\n=== Warm Transfer Flow\n. Retell AI identifies a qualified, interested seller during conversation\n. Retell initiates a SIP transfer to the Five9 inbound queue designated for warm transfers\n. Five9 routes the call to the next available BDR\n. Retell AI provides a \"whisper\" message to the BDR (heard only by the BDR, not the seller):\n   \"Warm transfer from AI agent. Seller: \\{name}, Amazon seller in \\{category}, estimated $\\{revenue}/month. Interested in daily payouts. Key interest: \\{summary}.\"\n. BDR picks up and continues the conversation seamlessly\n. If no BDR available within 30 seconds: Retell AI apologizes to the seller and offers to schedule a callback\n\n=== Five9 Configuration\n* Create a dedicated skill group \"AI Warm Transfers\" in Five9\n* Assign all 3 BDRs to this skill group\n* Set max wait time to 30 seconds before failover\n* Configure whisper announcements from Retell webhook data\n\n== Inbound Call Handling\n\nConfigure your callback number to route to Retell AI for initial handling:\n. Retell AI answers with a greeting and identifies the caller (match phone number to HubSpot contact)\n. If recognized: \"Hi \\{name}, thanks for calling back. You spoke with one of our team about getting paid daily on your Amazon sales. Would you like to hear more or would you prefer I connect you with someone right now?\"\n. If new caller: \"Thanks for calling [Company]. We help Amazon and Walmart sellers get paid daily instead of waiting for marketplace payouts. Are you an Amazon or Walmart seller?\"\n. For interested callers: Attempt warm transfer to BDR. If none available, capture email and schedule callback.\n\n== TCPA Compliance\n\n* Only call during 8am–9pm in the seller's local timezone\n* Maintain internal Do Not Call list — sync with HubSpot suppression properties\n* Record all calls (Retell AI does this by default) — retain for 2 years\n* Provide opt-out mechanism during every call (\"If you'd prefer not to receive calls from us, just let me know and I'll make sure we don't call again\")\n* Check against national DNC registry monthly (integrate with DNC.com or similar API)\n* Maximum 3 call attempts per contact per 30-day period\n\n== Expected Metrics\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Metric | Target\n\n| Outbound calls/day | 100–200 (AI) + 200–300 (BDR via Five9)\n| AI call completion rate | 60–75%\n| AI connect rate (live person) | 20–30%\n| Warm transfer rate (of connections) | 15–25%\n| Callback requests | 10–15% of connections\n| Voicemails left | 40–50% of attempts\n| Demos booked from voice | 30–60/month\n|===\n"},{"component":"morpheus","module":"plans","path":"plans:email-agent.adoc","title":"Email Agent","description":"Proposed email agent covering sequence generation, sending, and reply handling. Only reply classification shipped.","url":"https://docx.payability.com/morpheus/plans/email-agent.html","navOrder":79,"source":"= Email Agent\n:description: Proposed email agent covering sequence generation, sending, and reply handling. Only reply classification shipped.\n\n== Purpose\n\nThe Email Agent autonomously generates personalized cold email sequences, sends them through Amazon SES, monitors replies, classifies intent, and routes hot leads to the BDR queue in HubSpot. It replaces third-party tools like Instantly or Smartlead with a fully custom system.\n\n== Email Generation with Claude API\n\nEach email is generated dynamically using the Claude API. The agent receives seller context from HubSpot (business name, category, estimated revenue, marketplace, trigger events) and generates a personalized email using one of three sequence frameworks.\n\n=== Prompt Structure for Email Generation\n----\nSystem: You are writing cold outreach emails for a daily payout service for Amazon/Walmart sellers.\nThe service lets sellers access their marketplace earnings every day instead of waiting 2-3 weeks.\nFee is 2-3% of sales volume. Not a loan — no interest, no credit check, no debt.\nThe company has 100+ active sellers using the service.\n\nRules:\n- Under 120 words per email\n- Never say \"factoring\" — say \"daily payouts\" or \"get paid every day\"\n- Never open with \"I hope this finds you well\" or \"My name is...\"\n- One idea per email\n- Soft CTA only — \"Would it be worth a 15-minute call?\" not \"Sign up now\"\n- Tone: peer-to-peer, like a fellow seller sharing a tool, not a salesperson pitching\n- Personalize using the seller data provided\n\nSeller context:\n- Business name: {business_name}\n- Marketplace: {marketplace}\n- Primary category: {category}\n- Estimated monthly revenue: {revenue}\n- Trigger event: {trigger_event}\n- Sequence: {sequence_type}\n- Email number: {email_number} of 4\n\nGenerate only the email subject line and body. No preamble.\n----\n\n=== Three Sequence Frameworks\n\n*Sequence A: The Cash Flow Calculator* — Default for medium-priority leads (score 40–69).\n. Day 1: Personalized observation + \"Do you know how much cash is sitting in Amazon right now?\" + calculator link\n. Day 3: Brief case study of similar seller. Three sentences max.\n. Day 7: Address #1 objection (\"Is this a loan?\"). Soft CTA.\n. Day 14: Breakup email + testimonial.\n\n*Sequence B: The Seasonal Trigger* — Used when `trigger_event` = seasonal_prep or when approaching Q4/Prime Day.\n. Day 1: Lead with upcoming season. Frame the cash flow challenge. Introduce daily payouts.\n. Day 3: Stat about sellers missing revenue targets due to cash flow during peak seasons.\n. Day 7: Case study of seller who capitalized on seasonal opportunity.\n. Day 14: Breakup with urgency about timeline.\n\n*Sequence C: The Payment Hold Recovery* — Used when `trigger_event` = payment_hold.\n. Day 1: Empathize with hold experience. Introduce daily payouts as insurance.\n. Day 3: How the service works during holds specifically. Tactical.\n. Day 7: Testimonial from seller who survived a hold.\n. Day 14: Breakup + helpful resource (payment hold guide).\n\n=== Sequence Assignment Logic\n* `trigger_event` = payment_hold → Sequence C\n* `trigger_event` = seasonal_prep → Sequence B\n* `trigger_event` = scaling_ads, new_launch, expanding_marketplace → Sequence A\n* `trigger_event` = none → Sequence A (default)\n\n== Sending Infrastructure (Amazon SES)\n\n=== Domain Setup\nRegister 5 sending domains (variations of your brand name). For each domain:\n* Configure SPF, DKIM, and DMARC records\n* Set up SES domain identity verification\n* Create 3 mailboxes per domain (15 total mailboxes)\n* Warm each mailbox over 3 weeks: start at 20/day, increase by 10/day each week, cap at 80/day\n\n=== Runtime: AgentCore Runtime (`marketplace-email-agent`)\n\nThe Email Agent runs as an AgentCore Runtime deployment. It does not live in Lambda. Lambda functions are only used as tools called via AgentCore Gateway, and as thin webhook receivers that forward inbound events to the agent.\n\n*AgentCore services used by this agent:*\n* *AgentCore Runtime* — hosts the agent; handles both scheduled send runs and inbound reply events as separate session invocations\n* *AgentCore Memory* (`email-sequences` store) — per-contact sequence position, send timestamps, reply history, A/B test variant assignments, per-mailbox daily send counts; eliminates manual DynamoDB state tracking\n* *AgentCore Memory* (`cross-agent-leads` shared store) — reads contact lifecycle stage and score; writes reply outcomes visible to Voice and LinkedIn agents\n* *AgentCore Identity* — manages Claude API key, SES SMTP credentials per mailbox, HubSpot token\n* *AgentCore Gateway* — exposes `ses_send_email`, `ses_verify_email`, `hubspot_get_contact`, `hubspot_update_stage`, `hubspot_create_task`, `dynamo_check_suppression` as MCP tools\n\n*Triggers:*\n* Scheduled send run: EventBridge every 15 min → `dispatch-email-sender` Lambda → `InvokeAgentRuntime`\n* Inbound reply: SES receipt rules → SNS → `webhook-ses-inbound` Lambda → `InvokeAgentRuntime` with reply payload\n\n=== Sending Logic\n----\nAgentCore Runtime Session: email-send-run\nTrigger: EventBridge every 15 minutes, 8am–6pm ET Mon–Fri\n\nSteps:\n1. Read from AgentCore Memory (email-sequences): contacts in active sequences\n   needing their next email based on sequence timing\n2. Read per-mailbox daily send counts from AgentCore Memory — enforce limits\n3. Check suppression via `dynamo_check_suppression` tool (bounces, complaints,\n   unsubscribes, disqualified contacts)\n4. For each eligible send:\n   a. Select mailbox using round-robin from AgentCore Memory state\n   b. Call Claude API (via AgentCore Identity credential) to generate personalized email\n   c. Call `ses_send_email` tool with selected mailbox and generated content\n   d. Update AgentCore Memory: increment mailbox send count, advance sequence position,\n      log send timestamp\n   e. Call `hubspot_update_stage` tool if lifecycle stage needs updating\n5. Respect per-mailbox limits: max 50/day during first month, 80/day after\n   (enforced from AgentCore Memory counters, not DynamoDB manual checks)\n----\n\n=== Total Daily Capacity\n* 15 mailboxes × 50–80 emails/day = 750–1,200 emails/day\n* Monthly: 15,000–25,000 emails (well above the 3,000–5,000 target, providing headroom)\n\n== Reply Handling\n\n=== Inbound Processing\n----\nAgentCore Runtime Session: email-reply-session\nTrigger: SES receipt rules → SNS → webhook-ses-inbound Lambda → InvokeAgentRuntime\n         (Lambda is a thin forwarder only; all reply logic runs inside AgentCore)\n\nSteps:\n1. Parse incoming email payload (reply body, sender, in-reply-to header) from\n   the invocation input\n2. Match to original outbound email via AgentCore Memory (email-sequences store)\n   — look up send record by in-reply-to message ID\n3. Classify reply intent using Claude API (via AgentCore Identity credential):\n   - \"interested\" → call `hubspot_create_task` tool (BDR alert), call\n     `hubspot_update_stage` tool (→ engaged), write to cross-agent-leads memory\n   - \"not_interested\" → call `hubspot_update_stage`, call `dynamo_check_suppression`\n     tool to add to suppression list, generate polite close via Claude + `ses_send_email`\n   - \"question\" → generate helpful response via Claude + `ses_send_email` tool,\n     log in AgentCore Memory and HubSpot\n   - \"out_of_office\" → update sequence timing in AgentCore Memory to reschedule next send\n   - \"unsubscribe\" → `dynamo_check_suppression` tool to add to suppression, confirm\n     removal via `ses_send_email`, update HubSpot\n   - \"wrong_person\" → suppression + attempt to find correct contact via\n     `hubspot_get_contact` tool\n4. Write reply classification and content summary to AgentCore Memory (email-sequences)\n   and update HubSpot via `hubspot_update_stage` tool\n----\n\n=== Reply Classification Prompt\n----\nClassify this email reply into exactly one category:\n- interested: The person wants to learn more, is open to a call, or asks how the service works\n- not_interested: The person explicitly declines or says they don't need the service\n- question: The person asks a specific question but hasn't committed to a call\n- out_of_office: Auto-reply indicating the person is away\n- unsubscribe: The person asks to be removed from emails\n- wrong_person: The person says this isn't relevant to them / they don't sell on Amazon/Walmart\n\nReply text: {reply_body}\n\nRespond with only the category name.\n----\n\n== Self-Optimization\n\nThe Email Agent continuously improves its own performance:\n* *Subject line testing:* For each sequence, generate 3 subject line variants. Track open rates by variant. After 100 sends per variant, promote the winner and generate a new challenger.\n* *Send time optimization:* Track reply rates by hour and day of week. After 30 days, shift send schedules toward highest-performing windows.\n* *Sequence routing:* Track demo booking rates by sequence type. Adjust default routing if one sequence consistently outperforms.\n* Optimization data stored in AgentCore Memory (email-sequences store). The agent reads this during each send session to self-tune. No separate Lambda function needed — the agent handles optimization as part of its own reasoning loop.\n\n== Compliance\n\n* Include unsubscribe link in every email (CAN-SPAM compliance)\n* Honor unsubscribes within 24 hours (actually immediate via suppression list)\n* Include physical mailing address in email footer\n* Maintain bounce rate under 3% — auto-pause mailboxes that exceed this\n* Maintain complaint rate under 0.1% — auto-pause if exceeded\n* Never email contacts in `disqualified` stage or on suppression list\n\n== Expected Metrics\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Metric | Target\n\n| Daily sends | 200–400 (ramp to 800+ by Month 3)\n| Open rate | 40–55%\n| Reply rate | 3–6%\n| Positive reply rate | 1–2%\n| AI-handled replies (questions, OOO, unsub) | 70–80% of all replies\n| BDR-escalated replies (interested) | 20–30% of all replies\n| Demos booked from email | 20–50/month\n|===\n"},{"component":"morpheus","module":"plans","path":"plans:linkedin-agent.adoc","title":"LinkedIn Agent","description":"Proposed LinkedIn agent for connection requests, InMail, and engagement sequencing. Not built.","url":"https://docx.payability.com/morpheus/plans/linkedin-agent.html","navOrder":80,"source":"= LinkedIn Agent\n:description: Proposed LinkedIn agent for connection requests, InMail, and engagement sequencing. Not built.\n\n== Purpose\n\nThe LinkedIn Agent handles semi-automated outreach on LinkedIn: sending personalized connection requests, follow-up message sequences, and content engagement. It operates through the LinkedIn API where available and browser automation where necessary, with human oversight for compliance with LinkedIn's terms of service.\n\n== Profile Optimization (Manual, One-Time Setup)\n\nBefore the agent begins outreach, manually optimize the LinkedIn profiles of the 3 BDRs and any company executives who will be sending connections:\n\n* *Headline:* Speak to the seller. Example: \"Helping Amazon & Walmart Sellers Get Paid Daily | Eliminate the 2-Week Payment Wait\"\n* *Banner image:* Reinforce the value prop with branded imagery.\n* *About section:* Lead with the seller's problem. Include social proof (100+ sellers, total payouts processed). Clear CTA and booking link.\n* *Featured section:* Best case study, cash flow calculator link, booking link.\n\n== Outreach Sequences\n\nThe LinkedIn Agent manages multi-step outreach sequences for each contact:\n\n=== Sequence Flow\n. *Connection request* (Day 0): Short, personalized note under 300 characters. No pitch. Reference their store, category, or a shared group.\n. *Thank you + value* (Day 1–2 after acceptance): Thank for connecting. Share a useful resource (blog post, data point about seller cash flow). No pitch.\n. *Soft introduction* (Day 4–5): Introduce what you do in context of a problem they likely face. Ask a question: \"Have you ever calculated how much cash you have locked up in Amazon at any given time?\"\n. *Case study + ask* (Day 10–12): Share a specific case study. Ask if they'd be open to a quick call.\n. *Breakup* (Day 20): Final message. Share a resource regardless. \"If cash flow timing ever becomes a bottleneck, here's how to reach us.\"\n\n=== Message Generation\nEach message is generated by Claude API with seller context from HubSpot:\n\n----\nSystem: You are writing LinkedIn messages for outreach to Amazon/Walmart sellers.\nThe goal is to build a genuine connection and introduce a daily payout service.\n\nRules:\n- Connection notes: under 300 characters, no pitch, reference something specific\n- Follow-up messages: under 100 words each\n- Never open with your product — lead with the seller or shared context\n- Use question-based approach, not statement-based\n- No jargon: \"daily payouts\" not \"factoring\" or \"receivables financing\"\n- One link max per message\n- Tone: genuine, curious, peer-to-peer\n\nSeller context:\n- Name: {name}\n- Business: {business_name}\n- Category: {category}\n- Marketplace: {marketplace}\n- LinkedIn activity: {recent_posts_or_activity}\n- Sequence step: {step_number}\n----\n\n== Implementation\n\n[NOTE]\n====\n*Why Nova Act is the right choice for LinkedIn automation*\n\nThe traditional approach — Playwright with session cookie rotation and residential proxies — works, but carries meaningful operational risk that is easy to underestimate. The infrastructure cost looks manageable, but the maintenance cost is where you bleed. Every time LinkedIn tweaks their UI, selectors break and require manual intervention. Session cookies expire unpredictably, proxy services add latency and cost, and cookie rotation logic adds complexity that compounds over time.\n\nNova Act was designed precisely for this class of problem. It brings three specific advantages here: human-in-the-loop approval patterns and real-time UI takeover for handling CAPTCHA and LinkedIn's occasional security challenges; session isolation and session replay for auditability and account safety monitoring; and a dramatically faster path from prototype to production — one CLI command handles Docker packaging, ECR push, IAM setup, and AgentCore Runtime deployment, which matters when a single developer is maintaining multiple agents.\n\nThe terms-of-service risk profile does not change meaningfully — you are automating LinkedIn regardless of the tooling. What changes is that maintenance burden drops significantly and reliability improves. Selector maintenance disappears as a recurring cost. Budget and plan around Nova Act as the primary executor for all LinkedIn actions.\n====\n\n=== Runtime: AgentCore Runtime (`marketplace-linkedin-agent`)\n\nThe LinkedIn Agent runs as an AgentCore Runtime deployment. All browser actions (Nova Act and Playwright) execute against *AgentCore Browser* — no self-managed Chrome, no ECS Fargate. Lambda is used only as a thin EventBridge dispatcher.\n\n*AgentCore services used by this agent:*\n* *AgentCore Runtime* — hosts the agent; each scheduled run is an isolated session\n* *AgentCore Memory* (`linkedin-sequences` store) — per-contact sequence step, per-profile daily action counts, acceptance tracking; replaces DynamoDB action counters\n* *AgentCore Memory* (`cross-agent-leads` shared store) — reads prior email/voice outcomes; writes LinkedIn engagement signals back for cross-agent awareness\n* *AgentCore Browser* — managed cloud browser; both Nova Act (primary) and Playwright (fallback) connect to it via WebSocket/CDP. Persistent browser profiles per BDR account managed by AgentCore Identity — no manual session cookie loading\n* *AgentCore Identity* — manages LinkedIn browser profiles (persistent session cookies per BDR account), Claude API key, HubSpot token\n* *AgentCore Gateway* — exposes `hubspot_get_contact`, `hubspot_update_stage`, `hubspot_create_task` as MCP tools\n* *AgentCore Policy* — enforces daily per-profile action caps as guardrails; agent cannot exceed limits regardless of reasoning loop\n\n*Trigger:* EventBridge every 2 hours → `dispatch-linkedin` Lambda → `InvokeAgentRuntime`\n\n=== AgentCore Runtime: `linkedin-outreach-session`\n----\nTrigger: EventBridge every 2 hours, 7am–9pm ET Mon–Fri\n\nSteps:\n1. Read from AgentCore Memory (linkedin-sequences + cross-agent-leads):\n   - Contacts needing next sequence action based on timing\n   - Skip contacts already responded to email/voice (from cross-agent-leads)\n   - Skip contacts in bdr_handoff or disqualified stage\n2. For each contact:\n   a. Read per-profile action counts from AgentCore Memory — AgentCore Policy\n      guardrail will block the tool call if daily limit is reached\n   b. Generate personalized message via Claude API (AgentCore Identity credential)\n   c. Execute via AgentCore Browser (Nova Act → Playwright fallback):\n      → Open AgentCore Browser session using persistent LinkedIn profile\n        (AgentCore Identity manages session cookies — no manual loading)\n      → Try Nova Act: connect to AgentCore Browser via CDP, execute task\n      → If Nova Act escalates/errors: Playwright connects to AgentCore Browser\n        via CDP, executes same task using CSS selectors\n      → If both fail: log to dead-letter list in AgentCore Memory, skip contact today\n   d. Write result to AgentCore Memory: advance sequence step, log executor used\n   e. Call `hubspot_update_stage` tool to log activity\n\n3. Inbox reply sweep (runs as part of same session):\n   a. Open AgentCore Browser, run Nova Act inbox check → Playwright fallback\n   b. For each new reply: classify intent via Claude API\n   c. \"interested\": call `hubspot_create_task` tool (BDR alert),\n      write to cross-agent-leads memory, update lifecycle → engaged\n   d. \"question\": generate response via Claude + send via AgentCore Browser task\n----\n\n=== Browser Execution Strategy\n\nAll LinkedIn browser actions follow a *Nova Act → Playwright fallback* pattern. Both executors connect to an *AgentCore Browser* session — the browser runs in AWS-managed cloud infrastructure. There is no ECS Fargate browser farm, no Lambda Chromium layer, and no external proxy service for either execution path.\n\n. *Nova Act (primary):* Connects to AgentCore Browser via WebSocket/CDP. Uses natural language task instructions. Adapts to LinkedIn UI changes without brittle selectors. Escalates explicitly on unexpected states.\n. *Playwright (fallback):* Also connects to AgentCore Browser via CDP (not a local browser). Uses CSS/XPath selectors. Invoked only when Nova Act escalates or errors.\n\nThe agent logs `action_executor: nova_act | playwright` to AgentCore Memory per action. Monitor the ratio via AgentCore Observability — rising Playwright usage signals a LinkedIn UI change that needs a selector update.\n\n'''\n\n=== LinkedIn API vs Nova Act (Browser Automation)\n* *LinkedIn API (preferred):* Use for content posting (company page), basic profile lookups, and any officially supported actions. Requires LinkedIn developer app with appropriate permissions.\n* *Browser automation (Nova Act → Playwright fallback):* LinkedIn's API does not support sending connection requests or messages at scale. Nova Act is the primary executor — its computer-use model handles dynamic UI naturally and escalates on friction instead of silently failing. Playwright is the fallback for when Nova Act cannot complete the action.\n\n=== Nova Act Agent: `linkedin-action-executor` (Primary)\n----\nDeployment: Amazon Bedrock AgentCore\nTrigger: Invoked by linkedin-outreach Lambda per queued action\nModel: Nova 2 Lite (low-latency computer-use model)\nMonitoring: CloudWatch — action success rate, escalation events, daily limit approach\n\nSupported task types (passed as natural language instructions):\n\n  Connection request:\n    \"Log into LinkedIn as {profile}. Navigate to this profile: {linkedin_url}.\n     Send a connection request with the following note (under 300 characters): {note}.\n     Confirm the request was sent successfully.\"\n\n  Message to connection:\n    \"Log into LinkedIn as {profile}. Open the message thread with {contact_name}.\n     Send the following message: {message_text}. Confirm delivery.\"\n\n  Inbox check (reply sweep):\n    \"Log into LinkedIn as {profile}. Open the messaging inbox. Return a list of\n     all conversations with new replies in the last 4 hours. For each, return:\n     sender name, sender profile URL, and message text.\"\n\n  Engagement (like/comment):\n    \"Log into LinkedIn as {profile}. Navigate to this post: {post_url}. Like it.\n     Then leave this comment: {comment_text}.\"\n\nSession management: Loads LinkedIn session cookie from Secrets Manager per profile.\nNova Act's step-by-step execution produces human-like timing — no explicit delays needed.\n\nTriggers fallback when:\n- Identity verification or security challenge screen appears\n- \"Connect\" button not found on profile page (UI layout change)\n- Message thread cannot be located\n- Task exceeds 60 seconds\n- Nova Act returns an escalation event for any reason\n----\n\n=== Playwright: `linkedin-action-playwright` (Fallback — via AgentCore Browser)\n----\nExecution: Playwright connects to AgentCore Browser via CDP (not a local Chrome instance)\nTrigger: Invoked by the LinkedIn Agent's reasoning loop when Nova Act escalates or errors\nRuntime: Python 3.12 + playwright (no headless Chromium layer needed — browser runs in AgentCore)\nProxy: Configured via AgentCore Browser proxy settings if needed (no external proxy service)\n\nSession management: Load LinkedIn session cookie from Secrets Manager.\nAdd random delays between actions: 30–90 seconds (uniform distribution).\nRandomize viewport (1200–1440px wide, 700–900px tall). Set realistic user-agent.\n\nConnection request:\n1. Navigate to {linkedin_url} via proxy\n2. Wait for profile page to load, locate \"Connect\" button\n   (selector: button[aria-label*=\"Connect\"] — update if LinkedIn changes)\n3. If \"Add a note\" option appears: click it, type {note}, click \"Send\"\n4. Confirm via success toast or button state change\n5. Return {success: true, executor: \"playwright\"}\n\nMessage to connection:\n1. Navigate to linkedin.com/messaging/\n2. Search for {contact_name} in conversation search\n3. Click conversation, type {message_text} into compose box, send\n4. Return {success: true, executor: \"playwright\"}\n\nInbox check (reply sweep):\n1. Navigate to linkedin.com/messaging/?filter=unread\n2. Scrape all visible conversation previews updated in last 4 hours\n3. For each: extract sender name, profile URL (from href), message preview text\n4. Return list of {sender_name, linkedin_url, message_text}\n\nEngagement (like/comment):\n1. Navigate to {post_url}\n2. Click Like button (selector: button[aria-label=\"Like\"])\n3. Click comment box, type {comment_text}, submit\n4. Return {success: true, executor: \"playwright\"}\n\nOn any Playwright failure: return {success: false, error: \"<reason>\", executor: \"playwright\"}\nand push action back to dead-letter SQS for manual review.\n----\n\n=== Daily Limits (Per LinkedIn Profile)\n* Connection requests: 20–30/day (conservative to avoid restrictions)\n* Messages to connections: 50/day\n* Profile views: 100/day\n* Content engagement (likes/comments): 30/day\n\nWith 3 BDR profiles + 1 executive profile = 80–120 connection requests/day total.\n\n== Content Posting\n\nThe LinkedIn Agent posts content to company and personal profiles:\n\n=== Posting Schedule\n* Company page: 3–5 posts/week (content generated by Content Agent)\n* BDR profiles: 1–2 posts/week each (rotate content, personalized intro)\n\n=== Content Types\n* Educational posts about cash flow, payment timing, working capital\n* Customer success stories (anonymized or with permission)\n* Industry commentary on Amazon/Walmart policy changes\n* Engagement polls: \"How long does it take you to get paid after a sale?\"\n* Repurposed snippets from blog posts\n\n=== Engagement Automation\nThe agent also engages with seller content to build visibility:\n* Like and comment on posts from target sellers (genuine, relevant comments generated by Claude)\n* Engage with posts in Amazon seller LinkedIn groups\n* This builds profile visibility so that when the connection request arrives, the seller has already seen your name\n\n== Integration with Other Agents\n\n=== Cross-Channel Sequencing\nLinkedIn is typically the third channel in the escalation sequence:\n. Email Agent sends 4-email sequence → no reply\n. Voice Agent attempts 3 calls → no connect\n. LinkedIn Agent sends connection request → starts LinkedIn sequence\n\nThe LinkedIn Agent checks HubSpot for prior activity before starting:\n* If the seller has already responded to email or voice: skip LinkedIn outreach\n* If the seller is in BDR queue: skip LinkedIn outreach\n* If the seller has been disqualified: skip\n\n=== Warm Lead Signal\nWhen a seller accepts a LinkedIn connection AND replies positively to a message:\n* Update HubSpot lifecycle stage to `engaged`\n* Create BDR task: \"LinkedIn warm lead — seller replied positively\"\n* BDR follows up via LinkedIn message or phone call\n\n== Tools & Infrastructure\n\n* *LinkedIn Sales Navigator:* $80–$100/month per seat. Used for advanced search, lead lists, and InMail credits. Agent uses Sales Navigator's search API (where available) for prospect identification.\n* *Browser automation (primary):* Amazon Nova Act via Bedrock AgentCore. No headless Chrome Lambda layer, no ECS maintenance, no selector upkeep for the happy path.\n* *Browser automation (fallback):* Playwright connecting to AgentCore Browser via CDP. Invoked only when Nova Act escalates or errors. No ECS Fargate, no Lambda Chromium layer — the browser infrastructure is AgentCore Browser in both cases.\n* *Proxy:* Required only for Playwright fallback paths — rotating residential proxies ($100–$200/month). Nova Act does not require proxies; its sessions run on AWS-managed infrastructure. Budget proxies as a contingency line, not a fixed cost.\n* *Session management:* LinkedIn session cookies stored in AWS Secrets Manager per profile. Both Nova Act and Playwright fallback load cookies from the same Secrets Manager path. Rotate across 4 profiles (3 BDRs + 1 executive).\n* *Monitoring:* Native CloudWatch integration via Bedrock AgentCore. Set alarms on: Nova Act escalation rate per profile (>2/day = friction signal), action success rate (<95% = investigate), daily limit approach (>80% of cap = slow down).\n\n== Risk Management\n\nLinkedIn automation carries account restriction risk. Mitigate:\n* Stay well within daily limits (see above) — enforce in DynamoDB before invoking Nova Act, not after\n* *No manual delay injection needed.* Nova Act's step-by-step computer-use model already produces human-like timing between actions. Do not add sleep() calls — they add latency without benefit.\n* Don't automate on weekends or outside normal business hours (enforce via EventBridge schedule)\n* If a Nova Act task escalates due to a LinkedIn identity verification or warning screen: pause that profile's automation for 7 days, then resume at 50% volume. Nova Act's escalation signal (rather than a silent failure) is the early-warning mechanism.\n* Always have the BDR's real activity mixed with automation (they should manually engage too)\n* Keep connection request acceptance rate above 20% — if it drops below, improve targeting or personalization. Monitor via DynamoDB acceptance tracking, not inside Nova Act.\n\n== Expected Metrics\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Metric | Target\n\n| Connection requests/week (total, all profiles) | 400–600\n| Acceptance rate | 25–40%\n| Response rate (after acceptance) | 15–25%\n| Positive response rate | 5–10%\n| Demos booked from LinkedIn/month | 15–30\n|===\n"},{"component":"morpheus","module":"plans","path":"plans:content-agent.adoc","title":"Content Agent","description":"Proposed inbound content generation and distribution agent. Not built.","url":"https://docx.payability.com/morpheus/plans/content-agent.html","navOrder":81,"source":"= Content Agent\n:description: Proposed inbound content generation and distribution agent. Not built.\n\n== Purpose\n\nThe Content Agent autonomously generates SEO-optimized blog posts, social media content, lead magnets, and email nurture sequences. It uses the Claude API for content generation, publishes to the Vercel-hosted marketing site, and distributes across social channels. Human review is optional but recommended for high-visibility pieces.\n\n== Runtime: AgentCore Runtime (`marketplace-content-agent`)\n\nThe Content Agent runs as an AgentCore Runtime deployment. The pipeline that was previously a Step Function is now the agent's reasoning loop — the agent decides what to generate, generates it, and publishes it in a single long-running session (up to 8 hours covers a full weekly content run).\n\n*AgentCore services used by this agent:*\n* *AgentCore Runtime* — hosts the agent; weekly sessions handle the full generate → review → publish → distribute pipeline\n* *AgentCore Memory* (`content-calendar` store) — published article index, keyword coverage map, scheduled topics, internal link map; replaces DynamoDB content tracking\n* *AgentCore Memory* (`cross-agent-leads` shared store) — writes new email nurture sequences so the Email Agent can pick them up\n* *AgentCore Identity* — manages Claude API key, LinkedIn API token, HubSpot token, Vercel deploy token, Ahrefs/Semrush API key\n* *AgentCore Gateway* — exposes `hubspot_create_contact` (for lead magnet captures), `ses_send_email` (newsletter blast coordination) as MCP tools\n\n*Trigger:* EventBridge weekly Monday 6am ET → `dispatch-content` Lambda → `InvokeAgentRuntime`\n\n== Content Generation Pipeline\n\n=== AgentCore Runtime: `content-pipeline-session`\n----\nTrigger: EventBridge weekly on Monday at 6am ET\n\n1. Topic Selection\n   → Query SEO keyword API (Ahrefs/Semrush) for volume + difficulty data\n   → Read AgentCore Memory (content-calendar): existing keyword coverage, published topics\n   → Agent reasons to select 2–4 topics for the week, assigns content type\n   → Write topic plan to AgentCore Memory for session continuity\n\n2. Draft Generation (per topic)\n   → Call Claude API (via AgentCore Identity) with topic brief, keyword, word count\n   → Generate full draft: meta title, meta description, H2/H3 structure, body, CTAs\n   → Store draft in S3; write draft reference to AgentCore Memory\n\n3. Review Queue (optional)\n   → If auto-publish enabled: proceed directly to publishing\n   → If manual review: agent writes draft status to AgentCore Memory, session suspends.\n     Resumes when Vercel review webhook calls InvokeAgentRuntime with approval signal.\n     (AgentCore Runtime supports async session suspension and resumption natively)\n\n4. Publishing\n   → Blog posts: push to CMS via Vercel/headless CMS API\n   → Social: push to LinkedIn company page via LinkedIn API\n   → Lead magnets: generate PDF via Vercel API route, upload to S3, create landing page\n   → Email nurture: write sequences to AgentCore Memory (cross-agent-leads store);\n     Email Agent reads them during its next send session\n\n5. Distribution\n   → Post to LinkedIn profiles via LinkedIn API\n   → Coordinate newsletter blast via `ses_send_email` tool\n   → Generate social snippets; store in S3 for manual posting on other platforms\n   → Submit URL to Google Search Console API\n   → Update internal link map in AgentCore Memory (content-calendar store)\n   → Log all published content to AgentCore Memory for future keyword deduplication\n----\n\n== Content Types & Prompts\n\n=== Blog Posts (1,500–2,500 words)\n----\nSystem: You are a content writer specializing in Amazon and Walmart marketplace\nseller education. You write for an audience of mid-market sellers ($100K–$1M/year)\nwho are business-savvy but not finance experts.\n\nRules:\n- Write in seller language: SKUs, ASINs, PPC, ACoS, FBA, FBM, BSR, Buy Box\n- Use specific numbers and examples throughout\n- Never say \"factoring\" or \"receivables financing\" — say \"daily payouts\" or\n  \"early payout services\"\n- Include actionable takeaways the reader can implement immediately\n- Mention [Company] product naturally as one solution, not as a hard sell\n- Write in a practical, peer-to-peer tone — like advice from an experienced seller\n\nArticle brief:\n- Target keyword: {keyword}\n- Secondary keywords: {secondary_keywords}\n- Content pillar: {pillar}  (Cash Flow Management | Payment Education | Seller Growth)\n- Word count: {word_count}\n- Include: {specific_sections_or_angles}\n- Internal links to include: {urls}\n- CTA: {cta_type} (calculator | demo | lead_magnet)\n\nGenerate: meta title (under 60 chars), meta description (under 155 chars),\nfull article with H2/H3 structure, and a CTA paragraph.\n----\n\n=== Priority Content Pillars\n\n*Pillar 1 — Cash Flow Management for Sellers:*\n* \"How to Calculate Your Amazon Cash Flow Gap\"\n* \"5 Ways Payment Delays Hurt Your Amazon Business\"\n* \"Cash Flow Planning for Q4: A Seller's Guide\"\n* \"Why Your Amazon Revenue Doesn't Match Your Bank Account\"\n* \"The Hidden Cost of Waiting for Amazon Payouts\"\n\n*Pillar 2 — Payment & Payout Education:*\n* \"Amazon Payout Schedule Explained (2026 Update)\"\n* \"When Does Walmart Pay Marketplace Sellers?\"\n* \"What Happens When Amazon Holds Your Payments\"\n* \"Amazon Payment Reserve: What It Is and What to Do\"\n* \"Amazon vs Walmart: Comparing Seller Payout Schedules\"\n\n*Pillar 3 — Seller Growth & Scaling:*\n* \"How to Fund Your Next Product Launch Without a Loan\"\n* \"Scaling Amazon PPC When Cash Flow Is Tight\"\n* \"Seller Financing Options Compared\"\n* \"How to Stock Up for Q4 When Cash Is Tied Up\"\n\n=== LinkedIn Posts (150–300 words)\n----\nSystem: Write a LinkedIn post for the [Company] profile targeting Amazon/Walmart\nsellers. The post should be educational and engaging, not salesy. Use short\nparagraphs (1–2 sentences each). Include a hook in the first line. End with\na question to drive engagement.\n\nTopic: {topic}\nAngle: {educational | case_study | industry_news | data_insight | poll}\n----\n\n=== Lead Magnets\nGenerate as long-form content, then convert to PDF using a Next.js API route on Vercel that renders HTML to PDF (using Puppeteer or react-pdf).\n\n. *Cash Flow Calculator* — Interactive React component on Vercel. Input: monthly revenue, marketplace. Output: locked capital estimate, daily payout amount, annual fee, net benefit. Gate behind email capture.\n. *\"Getting Paid Faster on Amazon\" Guide* — 15–20 page PDF covering all options.\n. *\"Q4 Cash Flow Playbook\"* — Seasonal guide, regenerated annually with fresh data.\n. *\"Payment Hold Survival Guide\"* — Tactical resource.\n\n=== Email Nurture Sequences (for content leads)\nWhen a seller downloads a lead magnet or subscribes to the blog, the Content Agent generates a 6-email nurture sequence written to AgentCore Memory (`cross-agent-leads` shared store). The Email Agent reads and sends them:\n\n* Day 0: Deliver the resource. Thank them.\n* Day 3: Related blog post or additional resource.\n* Day 7: Case study email.\n* Day 14: Soft introduction to daily payouts.\n* Day 21: Offer a demo or free cash flow analysis.\n* Day 30: Breakup + testimonial.\n\n== SEO Optimization\n\nEach blog post is optimized before publishing:\n* Primary keyword in: title tag, H1, first 100 words, one H2, meta description, URL slug\n* Secondary keywords distributed naturally throughout\n* Internal links to at least 2 other blog posts and the product/demo page\n* External links to authoritative sources (Amazon Seller Central help pages, industry reports)\n* FAQ schema markup for relevant articles (generated by Claude API)\n* Image alt text optimized for keywords\n\n== Publishing Infrastructure\n\n=== Vercel Marketing Site\n* Next.js app with MDX blog engine or headless CMS integration (Sanity, Contentful, or custom)\n* Content Agent pushes new posts via Git commit (using GitHub API) or CMS API\n* Automatic build and deploy on Vercel\n* Each blog post has: Open Graph meta tags, Twitter cards, schema markup, and analytics tracking\n\n=== Content CDN\n* All static assets (PDFs, images, infographics) served from S3 via CloudFront\n* Lead magnet PDFs stored in S3 with signed URLs (expire after download)\n\n== Distribution Automation\n\nAfter publishing, the Content Agent automatically:\n. Posts to LinkedIn company page via LinkedIn API\n. Generates 3 social media snippets for manual posting on other platforms\n. Adds to the next email newsletter batch (weekly digest to subscriber list)\n. Updates the internal link map in AgentCore Memory (content-calendar store) so future articles can reference it\n. Submits URL to Google Search Console for faster indexing\n\n== Expected Metrics\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Metric | Month 3 Target | Month 6 Target\n\n| Blog posts published/month | 8–12 | 12–16\n| LinkedIn posts/week | 3–5 | 5–7\n| Monthly organic traffic | 2,000–5,000 | 8,000–15,000\n| Blog-to-lead conversion | 2–4% | 3–6%\n| Lead magnet downloads/month | 100–300 | 400–800\n| Content-sourced demos/month | 10–20 | 25–50\n| Keywords ranking page 1 | 10–20 | 30–60\n|===\n"},{"component":"morpheus","module":"plans","path":"plans:re-engagement-reporting.adoc","title":"Re-engagement reporting","description":"A catalogue of the reports proposed to measure whether the five re-engagement segments actually produce calls, connections, and pipeline.","url":"https://docx.payability.com/morpheus/plans/re-engagement-reporting.html","navOrder":82,"source":"= Re-engagement reporting\n:description: A catalogue of the reports proposed to measure whether the five re-engagement segments actually produce calls, connections, and pipeline.\n\n[IMPORTANT]\n====\nNone of the reports on this page are built. This is a proposed reporting surface for the\nsegments described in xref:business:re-engagement.adoc[Re-engagement segments], written to\ndecide what is worth building and in what order.\n\nThree of them can be built against data that exists today. The rest depend on an enrolment\nlog that does not yet exist — see xref:#blockers[What blocks most of this].\n====\n\n== Why this page exists\n\nThe five segments enrol contacts onto call lists every few hours, and there is currently no\nmeasurement of what happens next. Each handler logs its own counters\n(`updated`, `assigned`, `skipped_no_hs_id`) to CloudWatch and nothing aggregates them; nothing\nat all tracks whether an enrolled contact was ever dialled, or whether a segment's priority\nscore predicts anything.\n\nThe unit of analysis is the *enrolment*: one contact, stamped by one segment, on one day. Every\nreport below is a different question asked of that unit.\n\n[mermaid]\n....\nflowchart LR\n  A[\"Segment enrols\\ncall_list_date stamped\"] --> B[\"Rep dials\\nCALL engagement\"]\n  B --> C[\"Connect\\nhs_call_status / disposition\"]\n  C --> D[\"Positive outcome\\nwarm lead, meeting, deal\"]\n  A -.->|\"never dialled\"| E[\"Leakage\"]\n  A -.->|\"suppression missed\"| F[\"Compliance risk\"]\n....\n\n== Classification used throughout\n\nSeveral reports below depend on a per-call outcome classification. The working definition,\nimplemented as a HubSpot custom report formula field:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Category | Rule\n\n| `Connected (Status)`\n| `hs_call_status = COMPLETED`, owner is not the AI fallback owner `86817215`, and the call\n  occurred on or after the contact's `call_list_date`\n\n| `Connected (Disposition)`\n| `hs_call_disposition` is one of the six positive outcomes, same owner and date conditions\n\n| `Blacklisted`\n| `hs_call_disposition` is one of the suppression outcomes. Deliberately *not* date-bound or\n  owner-bound — a disqualifying outcome counts whenever and by whomever it was recorded\n\n| `Not Connected`\n| Everything else\n|===\n\nThe date condition matters: without it, a call that predates the enrolment is credited to the\nsegment that enrolled the contact afterwards.\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Set | Dispositions\n\n| Positive\n| Account created, API Connected Confirmed, Connected, Follow up scheduled, Left live message,\n  Warm Lead (AE Follow Up)\n\n| Suppression\n| Blacklisted, Do not contact, Not a seller, No longer selling/shut down, Marketplace not\n  supported, Gatekeeper\n|===\n\n[NOTE]\n====\nBoth sets are judgement calls, not facts drawn from the portal. Two are worth revisiting:\n*Left live message* counts as positive without a live conversation having happened, and\n*Gatekeeper* sits in the suppression set although it describes a reached-the-wrong-person\noutcome rather than a disqualification. The authoritative GUID list is\n`FIVE9_DISPOSITION_TO_HUBSPOT_GUID` in\n`lambdas/morpheus_hubspot/utils/constants.py` and the `blocked_emails` CTE in\n`bigquery/v_email_clicked_re_engagement_summary.sql`, which currently disagree with each other\non membership.\n====\n\n[#effectiveness]\n== A. Effectiveness\n\nDoes re-engagement produce anything?\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Report | Grain | Metrics | Answers\n\n| Outcome by enrolment source\n| contact × `outreach_list_reason`\n| Share of each outcome category\n| Which of the five segments is worth keeping\n\n| Enrolment-to-outcome funnel\n| enrolment cohort by week\n| enrolled → dialled → connected → positive → meeting/deal\n| Where the drop-off is, per segment\n\n| Positive-disposition mix\n| call\n| Distribution across the six positive GUIDs\n| Whether a segment yields warm leads or only \"connected\" noise\n\n| Priority tier performance\n| `call_priority` 1–4\n| Connect rate, positive rate\n| Whether the priority formulas predict anything\n|===\n\nThe funnel deliberately mirrors the structure of the existing `v_call_campaign_funnel` view so\nthe two can be read side by side, but it is keyed on *enrolment* rather than on an AI call.\n\n[WARNING]\n====\nPriority tier performance is currently unmeasurable for one segment and skewed for all of them.\nThe lost-deal handler does not write `call_priority` at all — the line and\n`_score_to_call_priority()` are commented out — so its contacts carry whatever priority a\nprevious segment left behind. Any tier analysis silently attributes those rows to the wrong\ntier.\n====\n\n[#execution]\n== B. Execution hygiene\n\nAre reps working the list they are given?\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Report | Grain | Metrics | Answers\n\n| Time-to-first-call SLA\n| contact\n| Days from `call_list_date` to first non-AI call, bucketed same-day / 1–2 / 3–7 / never\n| How quickly enrolment turns into a dial\n\n| Never-called leakage\n| contact\n| Count enrolled with zero call engagements, split by rep, segment, enrolment age\n| How much enrolment volume is pure waste\n\n| Per-rep throughput\n| rep × day\n| Assigned, dialled, connected, untouched carry-over\n| Whether the list size matches rep capacity\n\n| Stale enrolment aging\n| contact\n| Days since `call_list_date` with no call\n| Which contacts are rotting on the list\n|===\n\nNever-called leakage is the report with the largest known number behind it already: contacts\nwith both `call_list_date` and `call_priority` populated and no associated CALL engagement of\nany kind currently number in the tens of thousands.\n\n[NOTE]\n====\nThis report also validates a design assumption in the campaign segment. Its ordering was\nchanged to `last_call_engagement_timestamp ASC NULLS FIRST` specifically so the queue would\ndrain itself — but as the handler's own note concedes, that property only holds if reps actually\ndial. Never-called leakage is how you find out whether it does.\n====\n\n[#assignment]\n== C. Assignment integrity\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Report | Grain | Metrics | Answers\n\n| Round-robin fairness\n| rep\n| `call_assigned_to` volume against an even split\n| Whether `select_next_sales_rep_round_robin` skews\n\n| Fallback-owner residue\n| contact\n| Count still owned by `86817215` after enrolment\n| How often reassignment silently fails\n\n| Assignment vs. actual caller\n| call\n| Rows where `call_assigned_to` ≠ the call's `hubspot_owner_id`\n| Whether routing is respected in practice\n|===\n\nFallback-owner residue is a direct check on a known failure path: when the round-robin returns\nno rep, four of the five handlers log a warning and continue without an owner, leaving the\ncontact enrolled but unrouted. Only the campaign segment aborts the batch instead.\n\n[#compliance]\n== D. Compliance and suppression\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Report | Grain | Metrics | Answers\n\n| *Blacklist leakage*\n| contact\n| Contacts with a suppression disposition that were subsequently re-enrolled, split by segment\n| Whether suppression is actually holding\n\n| Re-enrolment churn\n| contact\n| Enrolment count, distinct segments, total attempts, no progression\n| Who is being over-contacted\n\n| Cross-segment collision\n| contact × day\n| Contacts claimed by two or more segments in the same window\n| How often segments overwrite each other\n|===\n\nBlacklist leakage is the highest-priority report on this page, because the suppression rules are\nnot uniform across segments and the gap is structural rather than hypothetical. The supplier,\nno-show, and lost-deal views all exclude contacts carrying a bad disposition. The email-click\nview suppresses on a blocked-email set, but the handler's own Python filter only checks for a\nhuman call within two days — and the query wrapper filters on `deal_id IS NULL` alone. A\ncontact marked *Do not contact* or *Not a seller* can be re-enrolled if the blocked-email join\nmisses.\n\n[CAUTION]\n====\nCross-segment collision is not just a reporting curiosity. `call_list_date` is a single scalar\nproperty, so when two segments enrol the same contact on the same day the second write wins and\nthe first segment's `outreach_list_reason` and `call_priority` are overwritten. Attribution in\nxref:#effectiveness[section A] is wrong by exactly the collision rate until this is measured.\n====\n\n[#ops]\n== E. Pipeline health\n\n[cols=\"1,1,2,2\",options=\"header\"]\n|===\n| Report | Grain | Metrics | Answers\n\n| Handler run summary\n| handler × run\n| Rows returned, qualifying, updated, assigned, `skipped_no_hs_id`\n| Whether a segment has silently stopped working\n\n| Skip-reason breakdown\n| handler × reason\n| Called today, human call within 2 days, no HubSpot id, invalid `contactid`, no active rep\n| Whether a segment produces volume that is almost entirely filtered out\n\n| View yield against cap\n| BigQuery view\n| Rows returned against the query `LIMIT`\n| Whether real volume is being truncated\n|===\n\nThe handler run summary addresses a specific blind spot. Every segment returns early — with a\nwarning and no work — when `BIGQUERY_CREDENTIALS` is empty, when the query raises, or when the\nview returns nothing. All three look identical from outside: no enrolments. A total credential\noutage is indistinguishable from a quiet day.\n\nView caps to watch: 5000 for the email-click, supplier, and no-show views; 2000 for lost-deal.\n\n[#blockers]\n== What blocks most of this\n\nThree gaps, in increasing order of how much they block.\n\n`outreach_list_reason` is not written by every segment::\nThe lost-deal handler has the line commented out, so its enrolments carry no attribution at\nall. Every report keyed on segment silently drops or misattributes them. Fixing this is one\nuncommented line.\n\n`call_priority` is not written by every segment::\nSame handler, same cause. Priority tier analysis is unreliable until this is restored, because\nstale values from earlier enrolments are indistinguishable from real ones.\n\nThere is no enrolment history::\nThe blocker that matters. `call_list_date` is a single date property that every segment\n*overwrites*. Only the most recent enrolment survives, which means cohort funnels cannot be\nbuilt, re-enrolment counts cannot be computed, collisions are invisible after the fact, and\ntime-to-first-call can only be measured against the latest stamp rather than the one the rep was\nactually given.\n\nTwo ways to close the third gap:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Option | Shape\n\n| HubSpot custom event\n| An append-only `ReEngagementEnrolled` event per enrolment carrying segment, priority, assigned\n  rep, and source view. Follows the existing custom-event naming pattern for portal\n  `{hubspot-portal}` and lands in the warehouse with the rest of the CRM export.\n\n| Append-only table\n| An enrolment row written through `morpheus_data_layer` at the same time as the HubSpot PATCH.\n  Requires a Prisma migration in the web application first, since the schema is not owned here\n  — see xref:data:index.adoc[Data].\n|===\n\nThe custom-event route needs no schema change outside HubSpot and keeps the record next to the\nengagement data every report already joins against. The table route gives stronger guarantees\nand is queryable without waiting for an export interval.\n\n[#funnel-build]\n== Build spec: enrolment-to-outcome funnel\n\nThe first report built from this catalogue (section xref:#effectiveness[A], row 2). It lives in\nHubSpot's *custom report builder*, not in BigQuery: every property it reads is written to the\nportal by the segment handlers, so the report sits directly on the source of truth and needs no\nexport interval.\n\n=== Formula fields\n\nFour custom formula fields, each formatted as *Number* and aggregated as *Sum*. All share two\nguards — the call was made by a human, and it happened on or after the enrolment date.\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Field | Meaning\n\n| `stage_dialled`\n| A non-AI owner dialled on or after `call_list_date`\n\n| `stage_connected`\n| That call reached someone — `COMPLETED` status *or* a positive disposition\n\n| `stage_positive`\n| Positive disposition only, no status fallback\n\n| `stage_blacklisted`\n| Carries a suppression disposition. No date or owner guard, by design\n|===\n\n`stage_dialled`:\n\n[source]\n----\nIF([CALL.hubspot_owner_id] != '86817215' AND DATEDIFF(\"DAY\", [CONTACT.call_list_date], [CALL.hs_timestamp]) >= 0, 1, 0)\n----\n\n`stage_connected`:\n\n[source]\n----\nIF([CALL.hubspot_owner_id] != '86817215' AND DATEDIFF(\"DAY\", [CONTACT.call_list_date], [CALL.hs_timestamp]) >= 0 AND ([CALL.hs_call_status] = \"COMPLETED\" OR [CALL.hs_call_disposition] = 'f240bbac-87c9-4f6e-bf70-924b57d47db7' OR [CALL.hs_call_disposition] = 'cb091f0c-6c11-43e5-8351-b9464bd07080' OR [CALL.hs_call_disposition] = 'a4c4c377-d246-4b32-a13b-75a56a4cd0ff' OR [CALL.hs_call_disposition] = '2a86928b-2094-462e-a033-84ea14d77261' OR [CALL.hs_call_disposition] = '85db30fb-aa93-4f61-b9ba-28e6c181ae6d' OR [CALL.hs_call_disposition] = 'fb19cf15-0308-40cb-8b9d-7e432311eb09'), 1, 0)\n----\n\n`stage_positive`:\n\n[source]\n----\nIF([CALL.hubspot_owner_id] != '86817215' AND DATEDIFF(\"DAY\", [CONTACT.call_list_date], [CALL.hs_timestamp]) >= 0 AND ([CALL.hs_call_disposition] = 'f240bbac-87c9-4f6e-bf70-924b57d47db7' OR [CALL.hs_call_disposition] = 'cb091f0c-6c11-43e5-8351-b9464bd07080' OR [CALL.hs_call_disposition] = 'a4c4c377-d246-4b32-a13b-75a56a4cd0ff' OR [CALL.hs_call_disposition] = '2a86928b-2094-462e-a033-84ea14d77261' OR [CALL.hs_call_disposition] = '85db30fb-aa93-4f61-b9ba-28e6c181ae6d' OR [CALL.hs_call_disposition] = 'fb19cf15-0308-40cb-8b9d-7e432311eb09'), 1, 0)\n----\n\n`stage_blacklisted`:\n\n[source]\n----\nIF([CALL.hs_call_disposition] = 'b4544009-ea7a-4d3a-af90-84d10f16fb08' OR [CALL.hs_call_disposition] = '4d34d77a-64e1-45bf-8e27-679ef35ce634' OR [CALL.hs_call_disposition] = 'c70b6205-7185-4066-a78a-304205e764ca' OR [CALL.hs_call_disposition] = '9ae74d67-7118-4fa3-a715-4f32aab43bf3' OR [CALL.hs_call_disposition] = '685e7a0d-7335-4d2f-9e6a-fe8122d89bc2' OR [CALL.hs_call_disposition] = '2d471822-8c57-4071-a3b4-a461e4f92cc3', 1, 0)\n----\n\n[IMPORTANT]\n====\nWrite these on a single line. The formula editor has no `IN()` function — an attempt to use one\nfails with *\"Multiple disconnected expressions found where one was expected\"*, because the\nparser reads the comma-separated GUIDs as separate expressions. Chained `OR` equality is the\nonly form that validates, and line breaks inside the expression are also rejected.\n====\n\n[NOTE]\n====\nThe date guard is `DATEDIFF(\"DAY\", …) >= 0` rather than a plain comparison because\n`call_list_date` is a *date* property and `hs_timestamp` is a *datetime*. Truncating both to day\ngranularity is what makes a 3 PM call on the enrolment date evaluate to `0` instead of a\nnegative number.\n====\n\n=== Chart configuration\n\nHorizontal bar. Two configurations work, and they answer slightly different questions.\n\n*Option A — grouped bars, three measures*\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Data sources | Contacts + Calls (cross-object)\n| Y-axis (categories) | `outreach_list_reason`\n| X-axis (measures) | Sum of `stage_dialled`, `stage_connected`, `stage_positive`\n| Breakdown | *empty*\n| Display | Grouped\n| Sort | Descending by `stage_dialled`\n|===\n\nThe breakdown slot must stay empty: HubSpot does not allow multiple measures and a breakdown\nsimultaneously. Reads as drop-off left to right within each segment.\n\n*Option B — stacked bars, outcome as breakdown* (preferred)\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Setting | Value\n\n| Data sources | Contacts + Calls (cross-object)\n| Y-axis (categories) | `outreach_list_reason`\n| X-axis (measure) | Count of Contacts, *unique*\n| Breakdown | The four-output outcome label formula\n| Display | Stacked, or 100% stacked\n|===\n\nPreferred because it counts unique contacts rather than summing per-call values — see the\ndouble-count warning below — and because 100% stacked makes segments with very different\nvolumes directly comparable.\n\n=== Filters\n\n* `call_list_date` is known\n* `call_list_date` in the last 90 days\n* Optionally exclude owner `86817215` at report level rather than repeating it in each formula\n\n=== Companion report: Enrolled\n\nThe *Enrolled* stage cannot appear in the chart above. A cross-object Contacts + Calls report\ndrops contacts with no associated call — which is precisely the never-dialled population, and\nthe largest bar in the funnel.\n\nBuild it separately as a *single-object Contacts* report: count of unique Contacts, filtered to\n`call_list_date` is known, broken down by `outreach_list_reason`. Place the two side by side on\none dashboard. *Enrolled minus Dialled, per segment, is the leakage figure* that\nxref:#execution[section B] asks for.\n\n=== Known limitations\n\nSumming formula fields counts calls, not contacts::\nA contact dialled four times contributes `4` to every stage it qualifies for, so funnel widths\nstop being comparable. Either use Option B, or treat the formulas as *filters* on a\ncount-of-unique-Contacts metric rather than as summed measures.\n\nNo native funnel visualisation::\nHubSpot's funnel chart type accepts only lifecycle stages and deal stages, not arbitrary formula\nfields. A horizontal bar or a table is the available shape.\n\nSnapshot, not cohort::\nThe same overwrite problem described in xref:#blockers[the blockers section]. A contact enrolled\ntwice is attributed entirely to whichever segment wrote `call_list_date` last, and the earlier\nenrolment leaves no trace.\n\nUnattributed lost-deal rows::\nBecause the lost-deal handler does not write `outreach_list_reason`, its enrolments group under\na blank category. Worth an explicit \"Unknown\" label in the report rather than letting them read\nas a segment.\n\n== Build order\n\n[cols=\"1,1,3\",options=\"header\"]\n|===\n| Order | Report | Rationale\n\n| 1\n| xref:#compliance[Blacklist leakage]\n| Compliance exposure, buildable today, and the suppression asymmetry is already known\n\n| 2\n| xref:#execution[Never-called leakage]\n| Largest single quantified waste, buildable today\n\n| 3\n| xref:#effectiveness[Outcome by enrolment source]\n| Decides which segments to keep; buildable today with the collision caveat\n\n| 4\n| —\n| Close the enrolment-log gap\n\n| 5\n| Everything else\n| Unblocked by step 4\n|===\n\nSteps 1–3 need only the outcome classification and the properties that already exist. Nothing\npast step 3 should be started before the enrolment log lands, because those reports would be\nbuilt against a property that overwrites itself.\n\nRelated: xref:business:re-engagement.adoc[Re-engagement segments],\nxref:business:call-lists.adoc[Daily call lists],\nxref:business:call-logging.adoc[Call logging and dispositions],\nxref:data:bigquery-views.adoc[BigQuery views].\n"},{"component":"morpheus","module":"plans","path":"plans:bdr-playbook.adoc","title":"BDR Playbook (3 BDRs + Five9 Parallel Dialer)","description":"How three BDRs work a Five9 parallel dialer — roles, cadence, qualification, and handoff.","url":"https://docx.payability.com/morpheus/plans/bdr-playbook.html","navOrder":83,"source":"= BDR Playbook (3 BDRs + Five9 Parallel Dialer)\n:description: How three BDRs work a Five9 parallel dialer — roles, cadence, qualification, and handoff.\n\n== Role Definition\n\nThe 3 BDRs are the human layer of this system. In an AI-first architecture, their role shifts dramatically: they no longer do cold outreach from scratch. Instead, they handle high-value conversations that the AI agents have identified, qualified, and warmed up. Every minute a BDR spends should be on a seller who has already been educated about daily payouts and has expressed some level of interest.\n\n=== BDR Time Allocation (Target)\n* *40% — Warm transfers from Retell AI:* Live conversations with sellers the voice agent has qualified and transferred in real time.\n* *30% — Parallel dialer outbound (Five9):* Working through the AI-enriched, AI-scored hot list. These are contacts who have engaged with emails, clicked links, visited the site, or requested callbacks.\n* *20% — Demo calls:* Conducting the actual demo/sales conversation with qualified sellers.\n* *10% — Follow-up:* Nurturing leads who need one more touchpoint before committing to a demo.\n\n== Five9 Parallel Dialer Setup\n\n=== Campaign Configuration\n* *Campaign type:* Outbound, parallel dialer mode\n* *Lines per agent:* Start at 3:1 ratio (3 simultaneous dials per BDR). Adjust based on connect rates — if connect rate is high (>25%), reduce to 2:1 to avoid abandoned calls.\n* *Disposition codes:* Connected-Interested, Connected-Not Interested, Connected-Callback, Voicemail, No Answer, Wrong Number, DNC Request\n* *Dialing hours:* Tuesday–Thursday 10am–12pm and 2pm–4pm ET (peak). Monday/Friday 10am–3pm (secondary).\n* *DNC compliance:* Sync suppression list from HubSpot every hour. Never dial contacts marked as disqualified or DNC.\n\n=== Call Lists\nFive9 call lists are populated automatically from HubSpot via API integration:\n\n*Priority 1 — AI Callbacks:* Sellers who requested a callback from the Retell AI voice agent. These have the highest conversion rate. Dial within 4 hours of callback request.\n\n*Priority 2 — Hot Email Leads:* Sellers who replied \"interested\" to email sequences (classified by Email Agent). Dial within 24 hours of reply.\n\n*Priority 3 — High-Score Unreached:* Contacts with payout_pain_score >= 70 that the Retell AI voice agent could not reach after 3 attempts. The BDR's parallel dialer may catch them at different times.\n\n*Priority 4 — Engaged but Unstalled:* Contacts who clicked email links or visited the website but haven't replied or booked. Warm but need a human nudge.\n\n=== List Refresh\n* HubSpot → Five9 sync runs every 2 hours during business hours\n* Lists are dynamically prioritized: Priority 1 contacts always appear at the top\n* Contacts that have been dialed 5+ times in 30 days are automatically removed\n\n== Call Scripts\n\n=== Script 1: Warm Transfer Pickup\nThe BDR receives a whisper from Retell AI with seller context before connecting.\n\n----\n\"Hey {first_name}, this is {BDR name} — I believe you were just chatting with\nour team about getting paid daily on your Amazon sales. Thanks for your interest.\nI pulled up some info on your account. Based on what I can see, it looks like\nyou're doing about {estimated revenue} a month — does that sound roughly right?\"\n\n[Confirm revenue, adjust if needed]\n\n\"So at that level, you probably have somewhere around ${locked_amount} sitting\nin Amazon at any given time, just waiting for the next payout cycle. What we do\nis simple — we deposit your earnings into your bank account every single day.\nYou sell today, you get paid today. No loan, no interest, no credit check.\"\n\n[Pause for questions/reactions]\n\n\"The fee is typically [2-3]% of your sales volume. For most sellers at your level,\nthat works out to about ${monthly_fee_estimate} a month — and most of our sellers\ntell us it pays for itself because they can buy inventory faster, keep ads running,\nand avoid stockouts. Can I show you exactly how it would work for your business?\nI can do a quick 15-minute walkthrough right now or we can schedule one.\"\n----\n\n=== Script 2: Hot Email Lead Follow-Up\n----\n\"Hi {first_name}, this is {BDR name} from [Company]. You replied to an email\nfrom us about getting paid daily on your Amazon sales — I wanted to follow up\npersonally. Was there something specific that caught your attention about that?\"\n\n[Listen. Let them talk. Understand their specific situation.]\n\n\"Got it. So you're dealing with {their specific pain}. That's actually really\ncommon — most of the 100+ sellers we work with came to us for a similar reason.\nThe good news is this is a simple fix. Let me show you how it would work for\nyour business specifically — do you have 15 minutes now, or would tomorrow work\nbetter?\"\n----\n\n=== Script 3: Parallel Dialer Cold-Warm Call\nFor sellers who have been enriched and scored but haven't responded to AI outreach yet.\n\n----\n\"Hi {first_name}, this is {BDR name} from [Company]. We work with Amazon sellers.\nHave you got a quick minute?\"\n\n[If yes:]\n\"I'll be fast. I'm reaching out because we noticed you're doing really well\nin {their category} on Amazon. One thing we've been helping sellers like you\nwith is eliminating the two-week payout delay. Instead of waiting for Amazon\nto pay you every 14 days, we get your money to you every single day. Over 100\nsellers use us right now. Is that something you'd be open to learning about?\"\n----\n\n== Objection Handling Quick Reference\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Objection | Response\n\n| \"Send me an email\" | \"Happy to. What's the best address? And just so the email is useful — is it the cash flow part or the fee structure you'd want to see more about?\" (Qualify even in the deflection)\n| \"I use Amazon Lending\" | \"Amazon Lending is great for specific purchases. The difference with us is that it's not a loan — you're just getting your own money faster. No interest, no repayment schedule. A lot of our sellers actually use both. Want me to show you how they work together?\"\n| \"2-3% is too expensive\" | \"I hear you. Let me put it this way — if you're doing $50K/month, that's about $1,000-$1,500. But you're unlocking $25K in working capital every day. If that extra capital lets you avoid even one stockout or negotiate one better inventory deal, it's already paid for itself. Want me to run the math on your actual numbers?\"\n| \"I need to talk to my partner/spouse\" | \"Totally understand. Would it help if I did a quick walkthrough for both of you together? I can do it over Zoom, 15 minutes — that way you both have the full picture to discuss.\"\n| \"I'll think about it\" | \"Of course. Can I ask — what's the main thing you'd be weighing? I might be able to answer it right now and save you some time.\"\n|===\n\n== Demo Structure (15-Minute Walkthrough)\n\n. *Minutes 1–3: Confirm situation.* Verify their revenue, marketplace, and pain points. Make them feel heard.\n. *Minutes 3–7: Show the math.* Use the cash flow calculator to show their specific locked capital, daily payout amount, and annual cost. Make it concrete.\n. *Minutes 7–10: Product walkthrough.* Screen share the dashboard. Show how simple setup is. Show the Seller Visa Card if relevant.\n. *Minutes 10–12: Case study.* Share a testimonial or case study from a seller in their category or revenue range.\n. *Minutes 12–15: Next steps.* If they're ready: start signup. If they need time: set a specific follow-up date. Never end without a next step.\n\n== CRM Hygiene\n\nAfter every call, BDRs must:\n* Log the call in HubSpot with disposition and notes (Five9 auto-logs, but BDRs add context)\n* Update lifecycle stage if changed\n* Update `trigger_event` if new information learned\n* Set next activity date and type\n* If demo is booked: create a deal in HubSpot pipeline\n\n== Daily BDR Routine\n\n[cols=\"1,1\",options=\"header\"]\n|===\n| Time | Activity\n\n| 9:00–9:30 AM | Review AI overnight activity: new hot leads, warm transfer queue, email replies. Plan the day.\n| 9:30–10:00 AM | Follow up on yesterday's callbacks and pending demos.\n| 10:00–12:00 PM | *Prime calling block.* Parallel dialer on Priority 1–2 lists. Handle warm transfers from Retell AI.\n| 12:00–1:00 PM | Lunch + admin: update CRM, prep for afternoon demos.\n| 1:00–2:00 PM | Conduct scheduled demos.\n| 2:00–4:00 PM | *Second calling block.* Parallel dialer on Priority 3–4 lists. Handle warm transfers.\n| 4:00–5:00 PM | Follow-ups, send promised materials, update CRM, team sync (weekly).\n|===\n\n== Performance Targets per BDR\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Metric | Daily Target | Monthly Target\n\n| Dials (via Five9 parallel) | 80–120 | 1,600–2,400\n| Connections (live conversations) | 15–25 | 300–500\n| Warm transfers handled | 5–10 | 100–200\n| Demos booked | 3–5 | 60–100\n| Demos conducted | 2–4 | 40–80\n| Signups from demos | 0.5–1 | 10–20\n|===\n\nWith 3 BDRs combined:\n* Total demos booked: 180–300/month\n* Total signups: 30–60/month\n\nThese are aspirational targets at full scale (Month 3+). Month 1 will be lower as the system ramps.\n"},{"component":"morpheus","module":"plans","path":"plans:paid-ads.adoc","title":"Paid Advertising Playbook","description":"Proposed paid acquisition playbook running alongside outbound. Not built.","url":"https://docx.payability.com/morpheus/plans/paid-ads.html","navOrder":84,"source":"= Paid Advertising Playbook\n:description: Proposed paid acquisition playbook running alongside outbound. Not built.\n\n== Overview\n\nPaid ads are the one channel where the execution is mostly manual (campaign setup and management in Google/Meta platforms) but the infrastructure is custom-built: landing pages on Vercel, conversion tracking via custom events, and lead capture flowing directly into HubSpot where AI agents pick up the nurture.\n\n== Google Ads\n\n=== Search Campaigns\nTarget high-intent keywords:\n\n*Direct intent:*\n* \"Amazon seller daily payouts,\" \"get paid daily Amazon FBA,\" \"Amazon seller cash advance,\" \"marketplace seller financing,\" \"Walmart seller daily payout,\" \"Amazon seller early payout\"\n\n*Problem-aware:*\n* \"Amazon payout schedule too slow,\" \"Amazon holding my money,\" \"Walmart marketplace payment delay,\" \"when does Amazon pay sellers,\" \"Amazon payment hold how long\"\n\n*Alternative-seeking:*\n* \"Amazon lending alternative,\" \"e-commerce working capital,\" \"seller cash flow solutions,\" \"Amazon seller funding options\"\n\nExpected CPC: $3–$8. With $3K–$5K monthly budget, expect 400–1,500 clicks/month.\n\n=== Ad Copy\n* Headline 1: Benefit — \"Get Paid Daily on Amazon Sales\"\n* Headline 2: Pain — \"Stop Waiting 2 Weeks for Your Money\"\n* Headline 3: Proof — \"Trusted by 100+ Sellers\"\n* Description: \"Access your Amazon earnings every day. No loan, no interest, no credit check. Just your money, faster.\"\n* Sitelinks: \"How It Works,\" \"Case Studies,\" \"Calculate Your Savings,\" \"Book a Demo\"\n* Callouts: \"No Credit Check,\" \"2% Simple Fee,\" \"Same-Day Access\"\n\n=== YouTube Ads\n15–30 second pre-roll targeting Amazon seller education content. Cost: $0.03–$0.08/view.\n\n== Meta Ads (Facebook & Instagram)\n\n=== Audience Targeting\n* *Interest:* Amazon FBA, Jungle Scout, Helium 10, Amazon Seller Central, Shopify, e-commerce entrepreneurship, wholesale business\n* *Lookalike:* Upload 100+ customer list. Create 1%, 3%, 5% lookalikes.\n* *Retargeting:* Website visitors, video viewers, content engagers.\n\n=== Ad Creative Concepts\n. *Cash Flow Gap Calculator:* \"You sell $50K/month on Amazon. At any given time, $25K+ is locked up. What would you do with that money today?\"\n. *Before/After Testimonial:* Video/carousel showing a seller's experience before and after daily payouts.\n. *The Explainer:* 30-second motion graphic. End: \"No loan. No interest. Just your money, faster.\"\n. *Seasonal Urgency:* \"Black Friday is X weeks away. Do you have the cash to stock up?\"\n\nRotate creative every 3–4 weeks.\n\n== Custom Landing Pages (Vercel)\n\nEvery ad campaign points to a dedicated landing page on the Vercel-hosted Next.js site. Never send ad traffic to the homepage.\n\n=== Landing Page Architecture\n----\n/lp/daily-payouts         — Default for Google Search\n/lp/cash-flow-calculator  — Calculator-focused (Meta ads)\n/lp/q4-prep               — Seasonal (Q4 campaigns)\n/lp/payment-hold          — Payment hold audience\n----\n\n=== Landing Page Components (React/Next.js)\n* *Hero:* Headline matching the ad (message match), subheadline, primary CTA button\n* *Cash Flow Calculator:* Interactive component. Input: monthly revenue + marketplace. Output: locked capital, daily payout amount, annual fee, net benefit. Email gate on results.\n* *Social Proof Bar:* \"100+ sellers | $XXM in payouts processed | 4.8/5 rating\"\n* *3-Step How It Works:* Simple visual: Connect → We analyze → You get paid daily\n* *Testimonials:* 2–3 customer quotes with specific numbers\n* *FAQ Accordion:* Address top objections (Is this a loan? What's the fee? How long to set up?)\n* *CTA:* Demo booking (embedded Calendly or HubSpot meetings link) or \"Get Started\" signup flow\n\n=== Conversion Tracking\n* Custom events fired from Vercel API routes:\n** `calculator_submit` — Seller used the cash flow calculator\n** `email_captured` — Email submitted on any form\n** `demo_booked` — Demo scheduled via booking widget\n* Events pushed to Google Ads, Meta Pixel, and HubSpot via server-side tracking (Vercel API routes → platform APIs)\n* Server-side tracking preferred over client-side for accuracy and ad-blocker resilience\n\n=== A/B Testing\n* Use Vercel Edge Middleware for server-side A/B test routing\n* Test: headline copy, CTA text, calculator vs demo-first, video vs static hero\n* Route 50/50 traffic, measure conversion rate, declare winner after 200+ conversions per variant\n\n=== Lead Capture → AI Agent Pipeline\nWhen a seller submits their email on a landing page:\n. Vercel API route creates/updates HubSpot contact with `source_channel: paid_ad` and campaign info\n. HubSpot workflow triggers based on source:\n** If demo booked: create deal, assign to BDR\n** If email only (calculator, lead magnet): enroll in content nurture sequence via Email Agent\n** If high pain score (from calculator input): also trigger Voice Agent callback within 24 hours\n\n== Budget Allocation\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Platform | Monthly Budget | Goal\n\n| Google Search | $3,000–$5,000 | High-intent lead capture\n| Meta (FB/IG) | $1,500–$3,000 | Awareness + retargeting\n| YouTube | $500–$2,000 | Awareness + education\n|===\n\n== Expected Metrics\n\n[cols=\"1,1,1\",options=\"header\"]\n|===\n| Metric | Google Search | Meta\n\n| CPC | $3–$8 | $1–$3\n| CTR | 3–6% | 0.8–1.5%\n| Landing page conversion | 5–10% | 3–7%\n| Cost per lead | $30–$80 | $20–$60\n| Cost per demo booked | $80–$200 | $60–$150\n|===\n"},{"component":"oppa","module":"ROOT","path":"index.adoc","title":"OPPA","description":"What OPPA does and where to read next.","url":"https://docx.payability.com/oppa/index.html","navOrder":0,"source":"= OPPA\n:navtitle: Overview\n:description: What OPPA does and where to read next.\n\n{project-name} (Onboarding Process Part Automation) accepts Amazon seller invitations and drives the account-creation steps that follow.\nIt runs on Python {python-version} and AWS CDK v2.\nSource is in {repo-url}[{repo-name}].\n\n== What it does\n\nAn invite event starts a Step Functions orchestration.\nThe orchestration creates the Amazon account, sets up two-factor authentication, and waits for the invitation email.\nBrowser work is handed to Invitius over SQS.\nNotifications go to Slack, CloudWatch, and the event logger.\n\n== Where to read next\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| If you want… | Read\n\n| Lambdas, state machines, and data\n| xref:architecture.adoc[]\n\n| How to install dependencies and run tests\n| xref:development.adoc[]\n\n| How a change reaches an environment\n| xref:deployment.adoc[]\n|===\n"},{"component":"oppa","module":"ROOT","path":"architecture.adoc","title":"Architecture","description":"Lambdas, Step Functions, and the data OPPA keeps.","url":"https://docx.payability.com/oppa/architecture.html","navOrder":1,"source":"= Architecture\n:description: Lambdas, Step Functions, and the data OPPA keeps.\n\n{project-name} is one CDK application, `app.py`, with three stacks: the application stack, the CD pipeline, and the CI project.\nPackages live in the same repository and share one uv workspace.\n\n== Runtime pieces\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Piece | Role\n\n| `oppa-gatekeeper`\n| Idempotency check and lock release for an invite. Reads and writes the idempotency table.\n\n| `oppa-beeper`\n| Sends Slack, CloudWatch, and event-logger notifications. Runs inside the VPC.\n\n| `oppa-mailbox-checker`\n| Reads Gmail for Amazon invitation messages and stores the link. Runs inside the VPC. Depends on the Google API client in addition to the shared layer.\n\n| `oppa-common`\n| Shared library, installed as a Lambda layer (`public.ecr.aws/sam/build-python{python-version}`).\n\n| Amazon invite orchestrator\n| State machine started from the environment event bus. Calls the gatekeeper, then the three machines below.\n\n| Create account\n| State machine that drives Invitius account creation and records the result.\n\n| Two-factor authentication\n| State machine that drives Invitius 2FA setup.\n\n| Email lookup\n| State machine that waits until the mailbox checker has stored the invitation email.\n|===\n\nInvitius does the browser automation. OPPA sends it work on the Invitius SQS queue and reads uploads from the shared S3 bucket.\n\n== Data and integrations\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Resource | Use\n\n| Assistance table\n| Workflow and invitation records. Partition key `pk`, sort key `sk`, global secondary index `GSI1`. Point-in-time recovery is enabled. A DynamoDB stream uses `NEW_IMAGE`.\n\n| Idempotency table\n| One in-flight invite per supplier and marketplace. Items expire through the `expiration` TTL attribute.\n\n| Secrets Manager `oppa`\n| Runtime secret read by the beeper.\n\n| Secrets Manager `event_logger_auth_key`\n| Auth material for the event logger client.\n\n| Event bus `pyb-notifications`\n| Slack notification events.\n\n| Environment event bus\n| Starts the orchestrator. The name follows the environment (`development`, `staging`, or `default` in production).\n|===\n\nAccounts: development and staging use `{dev-aws-account}`. Production uses `{prod-aws-account}`. Region is `us-east-1`.\n\n== Code map\n\n[cols=\"1,2\",options=\"header\"]\n|===\n| Path | Contents\n\n| `{source-root}/app.py[app.py]`\n| CDK entry point. Defines the CI stack, the CD stack, and the application stage.\n\n| `{source-root}/oppa-cdk[oppa-cdk]`\n| Application stack, Lambda constructs, and the four state machines.\n\n| `{source-root}/oppa-common[oppa-common]`\n| Shared models, the event-logger client, and the layer image.\n\n| `{source-root}/settings.toml[settings.toml]`\n| Dynaconf settings. `PYB_ENV` selects the environment.\n|===\n"},{"component":"oppa","module":"ROOT","path":"development.adoc","title":"Local development","description":"Install OPPA with uv and run the local checks.","url":"https://docx.payability.com/oppa/development.html","navOrder":2,"source":"= Local development\n:description: Install OPPA with uv and run the local checks.\n\nInstall https://docs.astral.sh/uv/[uv] and Python {python-version} (`uv python install {python-version}`).\n\nThe private Payability packages are installed from GitHub tags, and `uv.lock` pins each tag to a commit:\n\n* `pybsdk` from https://github.com/Payability/pyb-sdk[Payability/pyb-sdk], tag `2.0.0`\n* `eventlogger` from https://github.com/Payability/EventLoggerPySDK[Payability/EventLoggerPySDK], tag `1.0.0`\n* `pyb-cdk-v2` from https://github.com/Payability/pyb-cdk-v2[Payability/pyb-cdk-v2], tag `v0.2.0`\n\n[source,console]\n----\n$ uv sync --frozen\n----\n\n`uv sync` clones those repositories with your normal git credentials.\n\n== Tasks\n\n[source,console]\n----\n$ make sync             # uv sync --frozen\n$ make format           # ruff format and autofix\n$ make lint             # ruff and mypy\n$ make test             # unit tests (with coverage) and integration tests\n$ make test-unit\n$ make test-integration\n$ make lambda-reqs      # write Lambda requirements.txt from uv.lock\n$ make synth ENV=development\n$ make deploy ENV=development\n----\n\nPass extra pytest arguments with `PYB_ENV=test uv run pytest tests/unit/ -x`.\n`make test-unit --` is not a way to forward arguments.\n\nUnit tests write `report_unit.xml` and `coverage.xml`.\nIntegration tests write `report_integration.xml`.\nPytest exit code 5 (no tests collected) is not a failure for the integration target.\n\nInstall the git hook with `uv run pre-commit install`.\n\n== Lambda requirements\n\n`make lambda-reqs` writes two files that are not committed:\n\n* `oppa-common/requirements.txt`, installed into the shared layer by `oppa-common/Dockerfile`\n* `oppa-mailbox-checker/requirements.txt`, bundled into that function. Packages already provided by `oppa-common` are removed.\n\n`make synth` and `make deploy` generate those files first.\n\nThe layer's Docker build installs `pybsdk` and `eventlogger` from GitHub.\nThat image is built on every synth, so every CDK command (`synth`, `diff`, `deploy`, including `deploy CD`) needs `GITHUB_TOKEN`.\n`make synth` and `make deploy` fill it from `gh auth token` when it is unset.\nWhen you call `npx aws-cdk` directly, run `export GITHUB_TOKEN=\"$(gh auth token)\"` first.\n\nLocal synth and deploy use CDK CLI `{cdk-cli-version}` through `npx`.\nThat CLI is versioned separately from `aws-cdk-lib`. An older global `cdk` cannot read the cloud assembly.\n"},{"component":"oppa","module":"ROOT","path":"deployment.adoc","title":"Deployment","description":"How the CDK v2 pipeline is updated and how later commits deploy.","url":"https://docx.payability.com/oppa/deployment.html","navOrder":3,"source":"= Deployment\n:description: How the CDK v2 pipeline is updated and how later commits deploy.\n\nEach environment has a CD pipeline that synthesizes and deploys the application stack.\nThe pipelines that are already deployed were created with CDK CLI 1.127 and cannot deploy a CDK v2 cloud assembly.\nBefore merging onto a pipeline branch, deploy that pipeline once from a workstation that has CDK CLI `{cdk-cli-version}`:\n\n[source,console,subs=\"+attributes\"]\n----\n$ uv sync --frozen\n$ make lambda-reqs\n$ export GITHUB_TOKEN=\"$(gh auth token)\"\n$ PYB_ENV=development npx --yes aws-cdk@{cdk-cli-version} diff\n$ PYB_ENV=development npx --yes aws-cdk@{cdk-cli-version} deploy CD -c branch_name=<branch>\n$ PYB_ENV=development npx --yes aws-cdk@{cdk-cli-version} deploy CI\n----\n\nRepeat the `CD` deploy for `staging` and `production`.\n`make lambda-reqs` writes the Lambda `requirements.txt` files that the common-layer Docker build copies, so run it before the first CDK command on a fresh checkout.\nThat Docker build clones `pyb-sdk` and `EventLoggerPySDK` from GitHub with `GITHUB_TOKEN`.\n`make deploy ENV=development` deploys every stack in the app (`CI`, `CD`, and the application stage). For `staging` and `production`, `make deploy` only deploys `CD` and requires the pipeline branch, for example `make deploy ENV=production BRANCH=release`. The pipeline then deploys the application stage. `make synth` and `make deploy` stop if `PYB_ENV_NAME` (from your shell or `.env`) renames a staging or production stack. Use the `deploy CD` and `deploy CI` commands above when only the pipeline or the pull-request project should change.\n\n`CD` is selected by `PYB_ENV` in `settings.toml` (`development`, `staging`, `production`).\n`CI` is the CodeBuild project that runs `buildspec.yaml` on pull requests.\n\nConfirm `cdk diff` updates the DynamoDB tables, state machines, functions, and rules in place.\nThe CDK app keeps the same construct IDs.\n\nAfter that first deploy, the pipeline uses the CodeBuild image `standard:7.0`, Python {python-version}, and CDK CLI `{cdk-cli-version}`.\nThe synth step is:\n\n[source,console,subs=\"+attributes\"]\n----\n$ pip install uv\n$ git config --global url.\"https://x-access-token:$\\{GITHUB_TOKEN}@github.com/\".insteadOf \"https://github.com/\"\n$ uv sync --frozen\n$ make lambda-reqs\n$ uv run cdk synth CD -c branch_name=$SOURCE_BRANCH_NAME\n----\n\n`PYB_ENV` and `PYB_ENV_NAME` are set on that step from `settings.toml`.\n`GITHUB_TOKEN` comes from the Secrets Manager secret `aws-codebuild-token`.\nCodeBuild installs `aws-cdk@{cdk-cli-version}` before those commands, so `uv run cdk` resolves to that CLI inside the pipeline.\n\nRoll back a bad application deploy by reverting the commit and letting `CD` deploy again.\nThe assistance table has point-in-time recovery enabled.\n"},{"component":"pyb-docs","module":"ROOT","path":"ai-agents.adoc","title":"Using these docs from AI agents","description":"Connect Claude Code, Cursor or another MCP client to the docs, or point an agent at llms.txt.","url":"https://docx.payability.com/pyb-docs/ai-agents.html","navOrder":0,"source":"= Using these docs from AI agents\n:description: Connect Claude Code, Cursor or another MCP client to the docs, or point an agent at llms.txt.\n\nThese docs are published in forms that coding agents can read cheaply.\nEverything below is only reachable from the company VPN.\n\n== MCP server (recommended)\n\n`https://docx.payability.com/api/mcp` is a read-only MCP server (Streamable HTTP, no auth beyond the VPN).\nIt has four tools:\n\n[cols=\"1,3\",options=\"header\"]\n|===\n| Tool | What it does\n\n| `list_components` | Every documented system, with a description, page count and URL.\n| `list_pages` | The pages of one component in navigation order, with path, title and description.\n| `get_page` | The full AsciiDoc source of one page, by URL or by component + path.\n| `search_docs` | Ranked full-text search over all pages, optionally within one component.\n|===\n\n=== Claude Code\n\n[source,shell]\n----\nclaude mcp add --transport http --scope user pyb-docs https://docx.payability.com/api/mcp\n----\n\n=== Cursor\n\nAdd to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one repo):\n\n[source,json]\n----\n{\n  \"mcpServers\": {\n    \"pyb-docs\": { \"url\": \"https://docx.payability.com/api/mcp\" }\n  }\n}\n----\n\n=== Other clients\n\nAny client that supports Streamable HTTP can use the URL directly.\nFor stdio-only clients, bridge it with `npx mcp-remote https://docx.payability.com/api/mcp`.\n\n== Static files\n\nIf you can't use MCP, these files are rebuilt with the site on every deploy:\n\n* https://docx.payability.com/llms.txt[`llms.txt`]: every component and page, with one-line descriptions and URLs.\n* https://docx.payability.com/llms-full.txt[`llms-full.txt`]: the AsciiDoc source of every page in one file (about 3 MB).\n* https://docx.payability.com/docs-index.json[`docs-index.json`]: the same content as JSON (components, pages, sources).\n* https://docx.payability.com/sitemap.xml[`sitemap.xml`]: every page URL.\n\nA line like this in a repo's `CLAUDE.md` or Cursor rules helps agents find them:\n\"Payability system docs: use the `pyb-docs` MCP server, or fetch https://docx.payability.com/llms.txt.\"\n\n== How it's built\n\n`extensions/llms-txt.js` in the pyb-docs repo writes the static files during the Antora build.\n`api/mcp.mjs` is a Vercel function that serves the MCP tools from the bundled `docs-index.json`.\nPage sources come back exactly as written, so attributes like `+{project-name}+` and `include::` lines are not expanded.\n"},{"component":"pyb-docs","module":"ROOT","path":"new-page.adoc","title":"New Page","description":"","url":"https://docx.payability.com/pyb-docs/new-page.html","navOrder":1,"source":"= New Page\n\nYou've found a new page.\n"},{"component":"pyb-docs","module":"ROOT","path":"index.adoc","title":"Payability Documentation","description":"Architecture, business rules, integrations and operating guides for every Payability system, in one place.","url":"https://docx.payability.com/pyb-docs/index.html","navOrder":2,"source":"= Payability Documentation\n:page-role: home\n:description: Architecture, business rules, integrations and operating guides for every Payability system, in one place.\n\n[#components]\n== Components\n\nEach component is documented end to end by the team that owns it. Pick a system to get started, or press kbd:[/] to search across all of them.\n\n=== Underwriting & onboarding\n\n[.cards]\n* xref:automatic-uw-tool::index.adoc[Automatic Underwriting Tool] Identity-verification and background-check automation for supplier underwriting.\n* xref:oppa::index.adoc[OPPA] Onboarding Process Part Automation: accepts Amazon seller invitations and drives the account-creation steps that follow.\n\n=== Internal operations\n\n[.cards]\n* xref:internal-admin::index.adoc[Internal Admin] The internal administration client staff use to onboard, underwrite, price, fund and report on suppliers.\n* xref:actionstools-app::index.adoc[Actions Tools App] Backend for the CRM components in Internal Admin and service desk: notes, files, tasks, notifications and activity stream.\n\n=== Sales & outreach\n\n[.cards]\n* xref:morpheus::index.adoc[AgentCore] Automated outbound sales platform that finds, enriches and scores marketplace sellers and feeds HubSpot.\n* xref:dialer-v2::index.adoc[Outreach] Multi-tenant outbound engagement platform running AI voice, email and multi-channel nurture campaigns.\n\n=== Seller experience\n\n[.cards]\n* xref:supplier-client::index.adoc[Supplier Client] The customer-facing Supplier Dashboard sellers use to onboard, access financing, connect marketplaces and run reports.\n"},{"component":"supplier-client","module":"ROOT","path":"index.adoc","title":"Supplier Client","description":"Developer reference for the Payability Supplier Client — an Angular SPA for supplier onboarding, financial products, reporting and account management.","url":"https://docx.payability.com/supplier-client/index.html","navOrder":0,"source":"= Supplier Client\n:navtitle: Supplier Client\n:description: Developer reference for the Payability Supplier Client — an Angular SPA for supplier onboarding, financial products, reporting and account management.\n\nThe *{product-name}* (`{repo-name}`) is the customer-facing single-page\napplication that Payability suppliers use to onboard, access financial products,\nmanage their marketplace connections, run reports and administer their account.\n\nThis component documents the application end to end: its architecture, its\nrouting and permission model, every NgRX slice, all business rules encoded in\nthe client, every third-party integration, and the complete analytics event\ncatalog.\n\n== At a glance\n\n[cols=\"1,3\"]\n|===\n| Repository | `{repo-name}`\n| Type | Angular {angular-version} single-page application\n| App version | {app-version}\n| Node | {node-version}\n| Routing | Hash-based (`useHash: true`)\n| State | NgRX {ngrx-version} (store, effects, entity, router-store)\n| UI kit | Angular Material + `@payability/pyb-ngx-components`\n| API client | `@payability/pyb-ng2-api` (generated services, DTOs, models)\n| Delivery | AWS CodeBuild (`buildspec.yaml`) → static bundle in `dist/`\n|===\n\n== Technology stack\n\n[cols=\"1,2,3\"]\n|===\n| Area | Library | Role\n\n| Framework | `@angular/*` {angular-version} | SPA framework, DI, router, forms\n| State | `@ngrx/store`, `@ngrx/effects`, `@ngrx/entity`, `@ngrx/router-store` | Reactive state, side effects, normalized collections\n| Immutability | `ngrx-store-freeze` | Dev-only meta-reducer that throws on state mutation\n| Permissions | `ngx-permissions` | Template directives + route guard driven by calculated permissions\n| i18n | `@ngx-translate/core` | All user-facing copy is translated; keys live in `src/assets/i18n/en.ts`\n| Tables | `ag-grid-enterprise` | Every report grid, with custom cell renderers\n| Banking | `ngx-plaid-link` | Bank account linking during onboarding and payment setup\n| Contracts | DocuSign (via `@payability/pyb-ng2-api`) | Legally binding signature flows\n| Cards | Marqeta (`MarqetaModule`), Airwallex | Seller Card issuance and management\n| Identity | `onfido-sdk-ui`, Entrust | KYC document capture and identity re-verification\n| Analytics | `ngx-segment-analytics`, `smartlook-client` | Product analytics and session replay\n| Metrics | `CloudWatchModule` (in-repo vendor module) | Page-load timing and error counters\n| Bot defence | `ng-recaptcha` (v3) | Login, registration and password-reset forms\n| State machines | `xstate` | Used by selected multi-step UI flows\n|===\n\n== Where to start\n\n[cols=\"1,2\"]\n|===\n| If you want to… | Read\n\n| Understand how the app is put together\n| xref:architecture/overview.adoc[Architecture Overview]\n\n| Find out why a supplier can or cannot see a feature\n| xref:security/permissions.adoc[Permissions System]\n\n| Trace a URL to the module that serves it\n| xref:routing/route-tree.adoc[Route Tree]\n\n| Understand the financial products\n| xref:business/domain-model.adoc[Domain Model]\n\n| Change a contract-signing flow\n| xref:business/docusign.adoc[DocuSign Contract Flows]\n\n| Add or rename an analytics event\n| xref:analytics/event-catalog.adoc[Event Catalog]\n\n| Build a form, or work out why one will not validate\n| xref:architecture/forms-and-fields.adoc[Forms & Fields]\n\n| Change the header, aside, subheader or a banner\n| xref:architecture/app-shell.adoc[Application Shell]\n\n| Know which API service to call\n| xref:integrations/api-client.adoc[API Client]\n\n| Fix a colour, a dark-mode bug, or add a white-label brand\n| xref:development/styling-and-theming.adoc[Styling & Theming]\n\n| Run the app locally\n| xref:development/setup.adoc[Local Setup]\n\n| Ship a new page\n| xref:development/adding-a-feature.adoc[Adding a Feature]\n|===\n\n== The five rules that explain most of the codebase\n\n[IMPORTANT]\n.Permissions are computed on the client, at login\n====\n`PermissionsService.getUserPermissions(user)` derives the full permission set\nfrom the `User` object every time the user is loaded or their status changes.\nNothing is stored server-side. Any behaviour that looks like \"the backend\ndecided\" is almost always a rule in\nxref:security/permissions.adoc[`permissions.service.ts`].\n====\n\n[IMPORTANT]\n.Only the Expedited schema unlocks products while pending\n====\nA supplier with `schema === OnboardingSchemaEnum.Expedited` (`exp_onb`) and a\n`Pending` / `PendingActivation` status can access Advance Line features before\nactivation. Every other schema must wait for `Active`.\nSee xref:business/onboarding.adoc[Onboarding].\n====\n\n[IMPORTANT]\n.DocuSign flows are guarded by a localStorage flag\n====\n`docusignFlowInitialized` is written to `localStorage` immediately before the\nbrowser leaves for DocuSign, and checked on return. Without the flag no business\naction runs, which prevents a bookmark or refresh from replaying a withdrawal.\nSee xref:business/docusign.adoc[DocuSign Contract Flows].\n====\n\n[IMPORTANT]\n.Seller Card eligibility is a three-way AND\n====\n`hasPaymentHistory` *and* (`pcardAvailable` *or* both addresses are US) *and*\nnot currently onboarding. `isFromUS()` requires the business address *and* the\nresidential address to be `US`.\nSee xref:business/seller-card.adoc[Seller Card].\n====\n\n[IMPORTANT]\n.Every user-facing action emits a Segment event\n====\nAll tracking goes through `EventLoggerService`. Adding a button without adding\nits event is a review-blocking omission.\nSee xref:analytics/event-logger.adoc[Event Logger].\n====\n\n== Scope of this component\n\nThis documents the *client application only*. It describes the API calls the\nclient makes and the DTOs it consumes, but the services behind\n`@payability/pyb-ng2-api` are documented by their own components in the central\nAntora site.\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/overview.adoc","title":"Architecture Overview","description":"Module hierarchy, layering rules, and the cross-cutting patterns used throughout the Supplier Client.","url":"https://docx.payability.com/supplier-client/architecture/overview.html","navOrder":1,"source":"= Architecture Overview\n:description: Module hierarchy, layering rules, and the cross-cutting patterns used throughout the Supplier Client.\n\nThe Supplier Client is a *feature-module* Angular application. A thin eagerly\nloaded shell boots the store, the translation layer and all third-party SDKs;\neverything a supplier actually looks at is a lazy-loaded module hanging off that\nshell.\n\n== The three layers\n\n[cols=\"1,2,3\"]\n|===\n| Layer | Location | Responsibility\n\n| Core\n| `src/app/core/`\n| Singletons. Auth, HTTP interceptors, NgRX root config, business services,\n  domain enums and models, guards, marketplace connectors. Imported *once*, by\n  `AppModule`.\n\n| Views\n| `src/app/views/`\n| Everything rendered. Lazy feature pages, the onboarding flow, shared widgets\n  and modals, and the authenticated app shell (`theme/`).\n\n| Vendors\n| `src/app/vendors/`\n| Thin in-repo wrappers around third-party SDKs (Plaid, CloudWatch) that expose\n  an Angular-idiomatic module with a `forRoot()` config.\n|===\n\n[WARNING]\n====\n`CoreModule` is a singleton and must never be imported by a feature module.\nFeature modules pull in what they need from `PartialsModule`, `MaterialModule`\nor the specific shared module that owns the component.\n====\n\n== Module graph\n\n[listing]\n----\nAppModule (eager, bootstraps AppComponent)\n│\n├── CoreModule ................ interceptors, pipes, directives, icon registry\n├── FeaturesModule ............ registers every NgRX feature + entity slice\n├── PartialsModule ............ shared widgets, modals, components\n├── ThemeModule ............... BaseComponent shell, header, aside, footer\n├── AuthModule.forRoot() ...... auth store slice + persistent meta-reducer\n├── OnboardingModule .......... onboarding shell (eager, routed lazily)\n│\n├── StoreModule.forRoot() ..... router reducer + metaReducers (storeFreeze in dev)\n├── EffectsModule.forRoot([]) . root effects registry\n├── StoreRouterConnectingModule ... router state → store (stateKey: 'router')\n├── StoreDevtoolsModule ....... logOnly instrumentation\n│\n├── PybApiModule.forRoot() .... Payability API client (base URLs from environment)\n├── MarqetaModule.forRoot() ... Seller Card issuance SDK\n├── PlaidModule.forRoot() ..... bank linking SDK\n├── SegmentModule.forRoot() ... analytics (loadOnInitialization only in prod)\n├── CloudWatchModule.forRoot() ... custom metrics endpoint\n├── PageLoadTimeTrackerModule.forRoot(['/dashboard']) ... page timing\n├── ServiceWorkerModule.register() ... PWA, enabled in production only\n├── NgxPermissionsModule.forRoot()\n└── TranslateModule.forRoot() ... local loader, default language `en`\n│\n└── AppRoutingModule\n    └── BaseComponent [AuthGuard]     ← the authenticated shell\n        ├── DashboardModule ............... lazy, [DocuSignGuard]\n        ├── ReportsModule ................. lazy\n        ├── ManageAccountModule ........... lazy, [TwoFactorAuthenticationGuard]\n        ├── SellerCardModule .............. lazy\n        ├── NewCardModule ................. lazy\n        ├── MyMarketplacesPageModule ...... lazy\n        ├── OnboardingModule .............. lazy\n        ├── ProductOfferingsModule ........ lazy, [DocuSignGuard]\n        ├── VendorPayIncomingReportsModule  lazy\n        ├── SendMoneyModule ............... lazy\n        ├── PartnersModule ................ lazy\n        ├── MoreFromPayabilityModule ...... lazy (referral)\n        ├── PayshieldSubscriptionModule ... lazy\n        ├── BlogPageModule ................ lazy\n        ├── SalesCallSummaryModule ........ lazy\n        └── DailySummaryModule ............ lazy, [NgxPermissionsGuard]\n----\n\nSee xref:routing/route-tree.adoc[Route Tree] for the URL each module serves.\n\n== `src/app/core` in detail\n\n[cols=\"1,3\"]\n|===\n| Folder | Contents\n\n| `_base/crud/`\n| `BaseModel`, `QueryParamsModel`, `QueryResultsModel`, `HttpUtilsService`,\n  `TypesUtilsService`. Generic paging/sorting helpers used by data services.\n\n| `_base/layout/`\n| Layout config service, subheader service, splash-screen service, translation\n  service, plus the shared pipe set (`safe`, `join`, `getObject`,\n  `firstLetter`, `addPercent`, `maskedBankAccount`, `replaceSentence`) and\n  directives (`contentAnimate`, `sticky`, `pybDisabled`).\n\n| `_base/tasks-flow/`\n| The `TasksFlow` / `TaskFlowBuilder` mini-engine that drives multi-step modal\n  wizards. See xref:architecture/flows-and-tasks.adoc[Flows & Tasks Engine].\n\n| `_config/`\n| `MenuConfig` (the whole navigation tree, permission-annotated),\n  `LayoutConfig`, `PageConfig`, `aggrid.config.ts`, and `URLS_ROUTER` — the\n  single source of truth for route strings.\n\n| `api-connectors/`\n| Marketplace API health monitoring for Amazon and Shopify.\n  See xref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\n| `auth/`\n| The complete authentication subsystem: actions, effects, reducers, selectors,\n  guards, interceptors, models, and `AuthService`.\n  See xref:security/authentication.adoc[Authentication].\n\n| `initializers/`\n| `APP_INITIALIZER` providers. See xref:architecture/bootstrap.adoc[Bootstrap].\n\n| `marqeta/`\n| Marqeta SDK wrapper module for Seller Card operations.\n\n| `material/`\n| `MaterialModule` — the curated set of Angular Material modules the app uses.\n\n| `pyb/`\n| All Payability business logic. Subdivided below.\n\n| `reducers/`\n| Root `AppState` type, `reducers` map (router only — everything else registers\n  via `forFeature`), and `metaReducers` (`storeFreeze` outside production).\n\n| `reports/`\n| Report infrastructure shared by every report page: AG Grid config, DI tokens,\n  daily-summary and fee-report helpers.\n|===\n\n=== `src/app/core/pyb` in detail\n\n[cols=\"1,3\"]\n|===\n| Folder | Contents\n\n| `animations/` | Reusable Angular animation triggers.\n| `components/` | AG Grid cell renderers, pagination, reCAPTCHA wrapper, dynamic container.\n| `configs/` | Icon registry, media registry, hyperlink service config.\n| `constants/` | Contact details, country codes, help-article IDs, LTC flow constants, banner factories.\n| `datasources/` | AG Grid server-side datasource adapters.\n| `dictionaries/` | Static lookup maps used by forms.\n| `entities/` | NgRX Entity collections — countries, marketplaces, supplier marketplaces, onboarding, daily summary report.\n| `enums/` | Every domain enum. See xref:reference/enums.adoc[Enum Reference].\n| `features/` | NgRX feature slices. See xref:state/feature-slices.adoc[Feature Slices].\n| `fields/` | Dynamic form field definitions and factories.\n| `flows/` | Composed multi-step business flows (advance line, LTC, re-sign contract).\n| `guards/` | `DocuSignGuard`, `HelpCenterRedirectGuard`, `TwoFactorAuthenticationGuard`, `TwoFactorInfoLoadedGuard`.\n| `interfaces/` | Cross-cutting TypeScript interfaces.\n| `models/` | Client-side domain models and DTO mappers.\n| `pipes/` | `filterBy` pipe.\n| `providers/` | Shared DI providers.\n| `services/` | ~40 domain services. See xref:reference/services.adoc[Service Catalog].\n| `tasks/` | Individual `FlowTask` implementations composed by `flows/`.\n| `utils/` | `Subscriber` base class, JWT decode, date helpers, error helpers.\n| `validators/` | Sync and async reactive-form validators.\n|===\n\n== Cross-cutting patterns\n\n=== Smart / presentational split\n\nContainer components select from the store and dispatch actions; presentational\ncomponents receive `@Input()` and emit `@Output()`. Presentational components do\nnot inject `Store`.\n\n=== `OnPush` everywhere\n\n`ChangeDetectionStrategy.OnPush` is the default. Components that mutate their own\nview model must either produce new references or call `markForCheck()`\nexplicitly.\n\n=== The `Subscriber` base class\n\n`src/app/core/pyb/utils/subscriber.class.ts` exposes an `ngUnsubscribe$` subject\nand completes it in `ngOnDestroy`. Any class holding long-lived subscriptions\nextends it and pipes through `takeUntil(this.ngUnsubscribe$)`. Services do this\ntoo — `DocusignVerificationService` extends `Subscriber`.\n\n=== Services are root-provided\n\nDomain services are declared `@Injectable({ providedIn: 'root' })`. Feature\nmodules only list a provider when the service is genuinely feature-scoped (for\nexample `ManageAccountService`, `ReportsService`, `ProductOfferingsService`).\n\n=== Configuration by injection token\n\nGrid column sets, banner definitions and connector configuration are supplied as\nDI tokens rather than hardcoded in components. `ReportsModule` alone provides\n`FEE_REPORT_COLUMNS_CONFIG_TOKEN`, `OUTSTANDING_BALANCE_COLUMNS_CONFIG_TOKEN`,\n`VENDOR_PAY_INCOMING_REPORT_COLUMNS_CONFIG_TOKEN` and several more. This makes\nthe same grid component reusable across reports.\n\n=== Routes come from `URLS_ROUTER`\n\n`src/app/core/_config/urls-route.const.ts` is a frozen (`as const`) object\nholding every route string. Navigation code, the menu config and redirect logic\nall read from it, so a URL change is a single-line edit.\n\n=== Hash routing\n\n`RouterModule.forRoot(routes, { useHash: true })`. All URLs carry a `#`, which\nis also why the DocuSign return URL is built by splitting on `#` in\n`DocusignVerificationService.generateRedirectUrl()`.\n\n== Global side-effect wiring\n\n`GlobalSubscriptionsService.init()` is the single place where session-scoped\nbackground behaviour is started. It is idempotent (guarded by an `initialized`\nflag) and it:\n\n. initializes the marketplace API connectors,\n. runs the Plaid bank-verification check,\n. mirrors calculated permissions into `NgxPermissionsService`,\n. subscribes to the welcome-message, Airwallex p-card terms and\n  identity-document-expired dashboard notifications and opens their modals,\n. evaluates whether the supplier must re-sign their contract,\n. initializes the account-cancelled, 2FA-setup, suspension-reason and\n  cancellation-status banners,\n. dispatches `fetchFees()`.\n\nSee xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/project-layout.adoc","title":"Project Layout","description":"Directory-by-directory map of the repository.","url":"https://docx.payability.com/supplier-client/architecture/project-layout.html","navOrder":2,"source":"= Project Layout\n:description: Directory-by-directory map of the repository.\n\n== Repository root\n\n[cols=\"1,3\"]\n|===\n| Path | Purpose\n\n| `src/` | Application source. See below.\n| `config/` | Node script that renders `src/environments/environment.ts` and `proxy.json` from templates. Run via `npm run pre-build`.\n| `proxy/` | Checked-in dev-server proxy configs for named backend environments.\n| `proxy.json` | Generated proxy config (output of `pre-build:proxy`).\n| `cypress/` | Cypress end-to-end specs and fixtures.\n| `e2e/` | Legacy Protractor scaffolding.\n| `tests/` | Shared test helpers.\n| `docs/` | This Antora documentation component. Consumed as a content source by the central Payability playbook; `start_path: docs`.\n| `angular.json` | Angular CLI workspace: build targets, budgets, asset globs, style preprocessing.\n| `buildspec.yaml` | AWS CodeBuild pipeline definition.\n| `Makefile` | Ergonomic wrapper over the npm scripts, parameterized by `ENV_NAME` / `ENV_TYPE`.\n| `ngsw-config.json` | Service worker caching strategy.\n| `.eslintrc.js`, `.prettierrc` | Lint and format configuration.\n| `.husky/`, `.lintstagedrc.json` | Pre-commit hooks (format staged, lint staged, tests on push).\n| `style-guide.md` | The adopted naming and structure conventions.\n|===\n\n== `src/`\n\n[cols=\"1,3\"]\n|===\n| Path | Purpose\n\n| `main.ts` | Bootstraps `AppModule`.\n| `polyfills.ts` | Zone.js and legacy browser polyfills.\n| `styles.scss` | Global stylesheet entry point.\n| `index.html` | Document shell, includes the splash screen markup.\n| `manifest.webmanifest` | PWA manifest.\n| `eventLogger.bundle.min.js` | Pre-bundled analytics helper loaded outside the Angular build.\n| `karma.conf.js`, `test.ts` | Unit test runner configuration and entry point.\n| `assets/i18n/en.ts` | Every translated string in the application.\n| `assets/media/` | Icons and images registered with `PybIconRegistryService`.\n| `environments/` | `environment.ts` (generated — never edit), `environment.prod.ts`, and `environment.service.ts` which types and exposes it.\n| `app/` | The application itself.\n|===\n\n[WARNING]\n====\n`src/environments/environment.ts` is *generated* by `config/pre-build.js` from\n`config/environment.template`. Editing it directly is lost on the next build.\nTo add a new setting, add it to the template *and* to `EnvironmentService`.\n====\n\n== `src/app/`\n\n[source]\n----\nsrc/app/\n├── app.module.ts               root NgModule\n├── app.component.{ts,html,scss}\n├── app-routing.module.ts       root route table\n├── translate-loader.ts         local (non-HTTP) ngx-translate loader\n│\n├── core/                       singletons — see Architecture Overview\n│   ├── _base/                  crud, layout, interfaces, tasks-flow\n│   ├── _config/                menu, layout, page, ag-grid, URLS_ROUTER\n│   ├── api-connectors/         amazon, shopify, abstract base\n│   ├── auth/                   the auth subsystem\n│   ├── initializers/           APP_INITIALIZER providers\n│   ├── marqeta/                Marqeta SDK wrapper\n│   ├── material/               curated Angular Material surface\n│   ├── pyb/                    all business logic\n│   ├── reducers/               root store config\n│   └── reports/                report infrastructure\n│\n├── vendors/\n│   ├── cloud-watch/            metric service + module + DI token\n│   └── plaid/                  Plaid Link module, tokens, constants\n│\n└── views/\n    ├── consts/                 view-layer constants\n    ├── onboarding/             the onboarding flow (own shell + steps)\n    ├── pages/                  lazy feature pages\n    ├── partials/               shared widgets, modals, components, content\n    ├── templates/              reusable form templates (e.g. simple-address-form)\n    └── theme/                  the authenticated shell — base, header, aside, footer, subheader\n----\n\n== `src/app/views/pages`\n\nEach folder is a lazy-loaded route target.\n\n[cols=\"1,2,2\"]\n|===\n| Folder | Route | Documented in\n\n| `auth/` | `/auth`, `/login` | xref:security/authentication.adoc[Authentication]\n| `dashboard/` | `/dashboard` | xref:features/dashboard.adoc[Dashboard]\n| `manage-account/` | `/manage-account` | xref:features/manage-account.adoc[Manage Account]\n| `reports/` | `/reports` | xref:features/reports.adoc[Reports]\n| `seller-card/` | `/seller-card` | xref:features/seller-card-pages.adoc[Seller Card Pages]\n| `newcard/` | `/newcard` | xref:features/seller-card-pages.adoc[Seller Card Pages]\n| `my-marketplaces/` | `/my-marketplaces` | xref:features/my-marketplaces.adoc[My Marketplaces]\n| `product-offerings/` | `/product-offerings` | xref:features/product-offerings.adoc[Product Offerings]\n| `vendorpay-incoming-reports/` | `/vendor-pay` | xref:business/vendor-pay.adoc[Vendor Pay]\n| `send-money/` | `/send-money` | xref:features/other-pages.adoc[Other Pages]\n| `partners/` | `/partners` | xref:features/other-pages.adoc[Other Pages]\n| `more-from-payability/` | `/referral` | xref:features/other-pages.adoc[Other Pages]\n| `payshield-subscription/` | `/payshield-subscription` | xref:features/other-pages.adoc[Other Pages]\n| `blog-page/` | `/blog` | xref:features/other-pages.adoc[Other Pages]\n| `sales-call-summary/` | `/sales-call-summary` | xref:features/other-pages.adoc[Other Pages]\n| `settings/` | (child modules reused by `manage-account`) | xref:features/manage-account.adoc[Manage Account]\n|===\n\n== `src/app/views/partials`\n\n[cols=\"1,3\"]\n|===\n| Folder | Contents\n\n| `components/` | ~25 UI primitives — buttons, banners, status badges/chips, image upload/preview, hint popovers, skeletons, card visuals, punch card, contact support.\n| `widgets/` | ~18 dashboard and page widgets, each bound to a store slice.\n| `modals/` | ~35 dialogs — product flows, Seller Card operations, 2FA, cancellation survey, legal info, transfer funds.\n| `content/` | CTA strategy factory, export dialogs, TabaPay iframe, general content blocks.\n| `layout/` | Splash screen, subheader, mobile report row.\n|===\n\nFull inventory: xref:reference/components.adoc[Shared Components].\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/bootstrap.adoc","title":"Bootstrap & Initialization","description":"What happens between page load and the first rendered route.","url":"https://docx.payability.com/supplier-client/architecture/bootstrap.html","navOrder":3,"source":"= Bootstrap & Initialization\n:description: What happens between page load and the first rendered route.\n\n== Sequence\n\n[listing]\n----\nindex.html loads\n  → main.ts bootstraps AppModule\n  → AppModule constructor\n      · tryAddNoIndex()   — adds <meta robots=noindex> for white-label hosts\n      · handlePlaid()     — captures Plaid OAuth redirect state from the URL\n  → APP_INITIALIZER providers run (in registration order, all awaited)\n  → StoreModule.forRoot() hydrates; AuthModule's persistent meta-reducer\n    restores the auth slice from localStorage\n  → AuthEffects.init$ fires: if an auth token exists, dispatch login({ authToken })\n  → login$ → userRequested → loadUser$ → getUserByToken()\n      · EventLoggerService.identify(user)\n      · dispatch userLoaded, userPermissionsRequested,\n                userDashboardStatusRequested, loadBalance\n  → loadedUserPermissions$ → PermissionsService.getUserPermissions(user)\n      → userPermissionsLoaded({ permissions })\n  → GlobalSubscriptionsService.init() mirrors permissions into NgxPermissions\n    and starts all session-scoped subscriptions\n  → Router activates BaseComponent (AuthGuard) and the requested lazy route\n----\n\n== `APP_INITIALIZER` providers\n\nDeclared in `src/app/core/initializers/app-initializer.provider.ts` and\nregistered in `AppModule.providers`.\n\n[cols=\"1,3\"]\n|===\n| Provider | Effect\n\n| `initializeLayoutConfigProvider`\n| Loads `LayoutConfig().configs` into `LayoutConfigService` if no config is set.\n\n| `initializePermissionsProvider`\n| Seeds `NgxPermissionsService` with the bootstrap permission `DASHBOARD_MODULE`\n  so the shell can render before the real permission set arrives.\n\n| `initializeTranslateProvider`\n| Sets and activates the default language, then *blocks* on the first\n  `onLangChange` emission. Nothing renders until translations are ready.\n\n| `initializePageLoadTimeTrackerProvider`\n| Wires `PageLoadTimeNotifierService` to `CloudWatchService` so page-load\n  durations are reported as custom metrics.\n\n| `initializeDailySummaryLoadDataErrorTrackerProvider`\n| Registers a CloudWatch counter for Daily Summary data-load failures.\n\n| `initializeThemeProvider`\n| `ClientProviderService.init()` — resolves the white-label / theme variant.\n\n| `initializeMediaProvider`\n| Registers the SVG icon set with `PybIconRegistryService` from\n  `assets/media` using `pybMediaRegistryConfig`.\n\n| `initializeAgGridLicenseProvider`\n| `LicenseManager.setLicenseKey(environment.agGridLicenseKey)`.\n|===\n\nTwo further providers are values rather than initializers:\n\n[cols=\"1,3\"]\n|===\n| `initializeMatFormFieldOptionsProvider`\n| `MAT_FORM_FIELD_DEFAULT_OPTIONS` = `{ floatLabel: 'always', appearance: 'outline' }`.\n\n| `setPerfectScrollbarConfigProvider`\n| Tunes `ngx-perfect-scrollbar` (`wheelSpeed: 0.5`, no wheel propagation).\n|===\n\n== `AppModule` constructor behaviour\n\n[source,typescript]\n----\nconstructor(private readonly meta: Meta, private readonly oauthTokenService: OauthTokenService) {\n    this.tryAddNoIndex();\n    this.handlePlaid();\n}\n----\n\n`tryAddNoIndex()`:: If the current URL contains any of `volusion`, `pro`,\n`lendio`, `neweggcapital`, `morecommerce`, `crm2`, `c2fo`, a\n`<meta name=\"robots\" content=\"noindex\">` tag is injected. These are white-label\nor partner-branded hosts that must not be indexed.\n\n`handlePlaid()`:: When the URL carries an `oauth_state_id` query parameter the\napp is returning from a Plaid OAuth redirect. The full URL is stored on\n`OauthTokenService.oauthToken`, the previously saved `link_token` is read out of\n`localStorage` into `OauthTokenService.linkToken`, and the `localStorage` entry\nis removed. `PlaidModule` picks these up to resume the Link session.\n\n== Third-party SDK configuration\n\nAll SDK configuration is read from `environment` at module-import time.\n\n[cols=\"1,2,2\"]\n|===\n| Module | Configured with | Notes\n\n| `PybApiModule.forRoot()`\n| `apiUrl`, `marketplaceUrl`, `frontendActionsToolsApiUrl`, `b2bDocumentUrl`, `gci`, `frontendTaskManager`, `documentsApiUrl`\n| The generated Payability API client.\n\n| `MarqetaModule.forRoot()`\n| `marquetaSandbox`\n| Seller Card issuance.\n\n| `PlaidModule.forRoot()`\n| `plaidSandbox`\n| Bank linking.\n\n| `SegmentModule.forRoot()`\n| `segmentKey`, `debug: false`, `loadOnInitialization: environment.production`\n| Segment only auto-loads in production; elsewhere it must be triggered manually.\n\n| `CloudWatchModule.forRoot()`\n| `cloudWatchApiUrl`\n| Custom metrics.\n\n| `PageLoadTimeTrackerModule.forRoot(['/dashboard'])`\n| Route allow-list\n| Only `/dashboard` is timed today.\n\n| `ServiceWorkerModule.register()`\n| `enabled: environment.production`, `registrationStrategy: 'registerImmediately'`\n| PWA caching is production-only.\n|===\n\n== Environment settings\n\n`config/environment.template` is rendered into `src/environments/environment.ts`.\n`EnvironmentService` (`src/environments/environment.service.ts`) types it.\n\n[cols=\"1,3\"]\n|===\n| Key | Meaning\n\n| `env`, `version`, `buildTime` | Build provenance, surfaced in the UI footer.\n| `production` | Toggles `storeFreeze`, Segment auto-load, the service worker and dev-only permissions.\n| `isMockEnabled` | Reserved for mocked data sources.\n| `authTokenKey` | The `localStorage` key holding the session token.\n| `apiUrl`, `authUrl` | Core Payability API endpoints.\n| `marketplaceUrl` | Marketplace service endpoint.\n| `frontendActionsToolsApiUrl`, `frontendTaskManager` | Frontend action/task services.\n| `b2bDocumentUrl` | Document service endpoint.\n| `gci` | Global client identifier passed to the API client.\n| `airwalexBaseUrl` | Airwallex card service base URL.\n| `tabapayUrl` | TabaPay iframe origin (card funding).\n| `agGridLicenseKey` | AG Grid Enterprise licence.\n| `segmentKey` | Segment write key.\n| `referralUrl` | Referral programme landing page.\n| `marquetaSandbox`, `plaidSandbox` | Sandbox toggles for the card and banking SDKs.\n| `cloudWatchApiUrl` | Metrics ingestion endpoint.\n| `zendeskWidgetUrl`, `zendeskWidgetKey` | Help centre widget.\n| `recaptchaKey` | reCAPTCHA v3 site key.\n| `smartLookKey` | Session-replay project key.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/app-shell.adoc","title":"Application Shell","description":"BaseComponent, the aside and header navigation, the subheader, layout/menu/page config and the splash screen.","url":"https://docx.payability.com/supplier-client/architecture/app-shell.html","navOrder":4,"source":"= Application Shell\n:description: BaseComponent, the aside and header navigation, the subheader, layout/menu/page config and the splash screen.\n\nEvery authenticated route renders inside `BaseComponent`\n(`src/app/views/theme/base/`). The shell owns the navigation, the page title, the\nglobal banners and the session-wide subscriptions — a feature page only supplies\nthe router-outlet content.\n\n== Composition\n\n[cols=\"2,3\"]\n|===\n| `theme/base/` | `BaseComponent` — the Material `mat-sidenav-container` hosting everything else, plus `BaseComponentService`.\n| `theme/aside/` | `AsideLeftComponent` — the live primary navigation, and `user-details/`.\n| `theme/header/` | Top bar, plus `header-mobile/` for the mobile toggle.\n| `theme/subheader/` | Page title, description and breadcrumbs.\n| `theme/footer/` | Footer with the build version.\n| `theme/brand/` | Logo and white-label branding.\n| `theme/html-class.service.ts` | Writes layout CSS classes onto `<body>`.\n| `partials/layout/splash-screen/` | The bootstrap loading screen.\n|===\n\n== `BaseComponent`\n\nTwo things happen in the *constructor*, before any lifecycle hook:\n\n[source,typescript]\n----\nconstructor(/* … */) {\n    super();\n    this.baseService.loadConfigs();          // <1>\n    this.globalSubscriptionsService.init();  // <2>\n}\n----\n<1> Loads `LayoutConfig`, `MenuConfig` and `PageConfig` into their services and\n    applies the resulting body classes.\n<2> Starts every session-scoped background subscription. Idempotent — see\n    xref:architecture/overview.adoc[Architecture Overview].\n\n[IMPORTANT]\n.The shell is where the session actually starts\n====\n`GlobalSubscriptionsService.init()` is called from `BaseComponent`, not from\n`AppModule` or an `APP_INITIALIZER`. Anything registered there therefore runs\nonly once the user reaches an authenticated route — never on the login screen and\nnever during the onboarding shell, which does not use `BaseComponent`.\n====\n\n`ngAfterViewInit` then performs the post-render work:\n\n[cols=\"2,3\"]\n|===\n| `clearOldMessagesFromCache()` | Drops dashboard-message dismissals older than seven days.\n| `showNewMessages()` | Fetches and shows the server-driven announcement banner.\n| `checkIfIOSUserAndFixPaddingIssue()` | Applies `pyb-ios-padding` and a pixel height for iOS Safari.\n| `sidebarFixer()` | Re-runs `updateContentMargins()` on the drawer container.\n|===\n\n`sidebarFixer()` reaches into two private Material fields:\n\n[source,typescript]\n----\n(this.sidenavContainer as any)._ngZone.onMicrotaskEmpty\n    .pipe(debounceTime(10))\n    .subscribe(() => {\n        this.sidenavContainer.updateContentMargins();\n        (this.sidenavContainer as any)._changeDetectorRef.markForCheck();\n    });\n----\n\n[WARNING]\n====\n`_ngZone` and `_changeDetectorRef` are private Material internals. The cast keeps\nthe drawer content margins correct when the aside is toggled, but it is the first\nthing to break on a Material upgrade.\n====\n\n=== Top bar versus banners\n\nThe onboarding status bar and the global banners are mutually exclusive:\n\n[source,typescript]\n----\nthis.topBarService.details$.subscribe(details => {\n    if (!details) {\n        this.canBannerBeVisible = true;\n        return;\n    }\n    this.hasTopBar = details.showStatusBar;\n    this.canBannerBeVisible = !details.showStatusBar;\n    this.cd.detectChanges();\n});\n----\n\nA supplier still in onboarding sees the progress bar; an activated supplier sees\nbanners. See xref:business/onboarding.adoc[Onboarding].\n\n=== Dashboard announcement banners\n\n`BaseComponentService` reads `DashboardMessageService.getSupplierDashboardMessage()`\nand shows anything whose `config_key` starts with `supplier_dashboard_message`, as\na `BannerTypes.ANNOUNCEMENT` banner. Dismissal is recorded in `localStorage` under\nthe message key, and `messageClosedWeekAgo()` expires those records after seven\ndays.\n\n[NOTE]\n.One message is deliberately un-dismissable\n====\n[source,typescript]\n----\nlocalStorage.removeItem(this.dashboardMessageKey);\n----\n\nThe exact key `supplier_dashboard_message` (without a suffix) has its dismissal\nrecord deleted on every shell init, and is rendered with `hideClose: true`. It\nreappears on every page load by design — the code comment cites task CA-744.\nSuffixed keys such as `supplier_dashboard_message_holiday` remain dismissable.\n====\n\n== Configuration objects\n\nThree plain classes in `core/_config/` describe the shell. They are loaded into\nmatching services by `BaseComponentService.loadConfigs()`.\n\n[cols=\"1,1,3\"]\n|===\n| Config | Service | Describes\n\n| `LayoutConfig` | `LayoutConfigService` | Skins, fixed/fluid widths, logos, the splash-screen loader, and a JS colour palette.\n| `MenuConfig` | `MenuConfigService` → `MenuAsideService` | The navigation trees.\n| `PageConfig` | `PageConfigService` → `SubheaderService` | Per-route title, description and breadcrumb text.\n|===\n\n`HtmlClassService.setConfig()` turns the layout config into `<body>` classes.\nThree of them are hardcoded rather than derived:\n\n[source,typescript]\n----\nprivate initSubheader() {\n    document.body.classList.add('kt-subheader--overlaped');\n    document.body.classList.add('kt-subheader--primary');\n}\n\nprivate initSkins() {\n    document.body.classList.add('pyb-aside-dark');\n}\n----\n\nChanging `subheader.style` or `aside.self.skin` in `LayoutConfig` therefore has no\neffect on the initial render — only `SubheaderService.changeStyle()` /\n`changeColor()`, which swap the classes at runtime, do.\n\n=== Menu configuration\n\n`MenuConfig` defines two trees, `header` and `aside`. Each item is:\n\n[source,typescript]\n----\n{\n    title: 'Seller Card',\n    root: true,\n    alignment: 'left',\n    page: URLS_ROUTER.SELLER_CARD.ROOT,\n    translate: 'MENU.SELLER_CARD',\n    permission: 'SELLER_CARD',\n    icon: 'pyb-credit-card',\n}\n----\n\n[cols=\"1,3\"]\n|===\n| `page` | Route path, always taken from `URLS_ROUTER`. xref:reference/path-aliases.adoc[Path Aliases]\n| `translate` | i18n key for the label. `title` is a developer-facing fallback.\n| `permission` | Dotted `PermissionsEnum` string. Omit for an ungated item.\n| `icon` | `pyb-*` icon from the custom registry, or a Material icon name.\n| `submenu` | Nested items, filtered independently.\n| `separator` | Renders a divider instead of a link.\n|===\n\nPermission filtering is done in the template, not in the service:\n\n[source,html]\n----\n<ng-container *ngIf=\"!item.permission\" [ngTemplateOutlet]=\"menuItemInnerTemplate\" …></ng-container>\n<ng-template *ngIf=\"item.permission\" ngxPermissionsOnly=\"{{ item.permission }}\">\n    <ng-container [ngTemplateOutlet]=\"menuItemInnerTemplate\" …></ng-container>\n</ng-template>\n----\n\nAn item without `permission` is always visible; an item with one is wrapped in\n`*ngxPermissionsOnly`. See xref:security/permissions.adoc[Permissions System].\n\n[WARNING]\n.The header menu tree is configured but not rendered\n====\n`LayoutConfig` sets `header.menu.self.display: false`, so the whole `header` tree\nin `MenuConfig` is dead configuration. It also contains several levels of\nself-similar nesting (Reports inside Reports inside Reports) with items that\nduplicate the aside entries and omit their permissions.\n\nThe only live consumer is `SubheaderService.updateBreadcrumbs()`, which searches\nthe header tree *first* and falls back to the aside tree. Editing the header tree\ntherefore changes breadcrumbs without changing navigation. Add new navigation to\nthe `aside` tree.\n====\n\n=== Product Offerings is filtered in code, not by permission\n\n`AsideLeftComponent` removes the Product Offerings entry for suppliers who are not\nyet activated:\n\n[source,typescript]\n----\nprivate getMenuWithOrWithoutProductOfferings(list: any[], isActivated: boolean): any[] {\n    if (isActivated) {\n        return list;\n    }\n    const newList = [...list];\n    const productOfferingsIndex = newList.findIndex(o => o.page === 'product-offerings');\n    newList.splice(productOfferingsIndex, 1);\n    return newList;\n}\n----\n\nThe item is matched by the *literal string* `'product-offerings'`, so renaming the\nroute in `URLS_ROUTER` silently breaks the filter: `findIndex` returns `-1` and\n`splice(-1, 1)` removes the *last* menu item instead. Activation state comes from\n`TopBarService.details$.isActivated`, not from a permission.\n\n== `SubheaderService`\n\nOwns everything above the page content. It re-derives its state on every\n`NavigationEnd`.\n\n[cols=\"2,3\"]\n|===\n| `title$` | `BreadcrumbTitle` — `title`, `desc`, `subDesc`.\n| `breadcrumbs$` | Resolved breadcrumb trail.\n| `visible$` / `disabled$` | Whole-subheader visibility. `BaseComponent` binds `visible$`.\n| `smallMode$` | Compact variant, via `smallSubHeader()` / `normalSubHeader()`.\n| `customPaddingBottom$` | Extra bottom padding — `PADDING_MOBILE` (80) or `PADDING_TABLET` (120).\n|===\n\nTitles resolve in this order:\n\n. a manual title previously set for this exact URL by `setTitle()`,\n. otherwise `PageConfig` for the cleaned URL,\n. breadcrumbs from the header tree, falling back to the aside tree.\n\nA single breadcrumb whose title matches the page title is dropped as redundant.\n\n[NOTE]\n.`setTitle()` is per-URL and permanent\n====\n`setTitle()` stores the title in a `manualTitle` map keyed by `router.url`, so it\nwins over `PageConfig` for the rest of the session. That is how\n`sales-call-summary` personalises its heading:\n\n[source,typescript]\n----\nthis.subheaderService.setTitle(`Welcome to Payability, ${this.titleCasePipe.transform(user.firstName)}!`);\n----\n\nThe `PageConfig` entry for that route still contains the literal placeholder\n`'Welcome to Payability, Alexis!'`. It is never displayed, because the component\noverrides it as soon as the user loads — but it is what you would see if that\nsubscription ever failed.\n====\n\n== Responsive behaviour\n\n`MenuAsideService` owns the drawer, driven by `BreakpointService`:\n\n[source,typescript]\n----\nget sidenavMode() {\n    return this.breakPointService.isMobileOrTablet() ? 'over' : 'side';\n}\n----\n\nOn every breakpoint change the drawer is closed for mobile/tablet and opened for\ndesktop. `BaseComponent.onMenuOptionClick()` also closes it after a navigation on\nsmall screens, so the menu does not stay over the page the user just opened.\n\n== Splash screen\n\nThe markup lives in `src/index.html` so it paints before Angular boots.\n`SplashScreenService.hide()` fades it out over 800 ms after a 300 ms delay and\nthen removes the element, guarding with a `stopped` flag so a second call is a\nno-op. The logo and message come from `LayoutConfig.loader`.\n\n== Adding a page to the shell\n\n. Add a `PageConfig` entry for the route key so the subheader has a title.\n. Add a `MenuConfig` entry under `aside.items`, with `page` from `URLS_ROUTER`,\n  a `translate` key, an `icon`, and a `permission` if the page is gated.\n. Add the translation keys to `src/assets/i18n/en.ts`.\n  xref:development/i18n.adoc[Internationalization]\n\nSee xref:development/adding-a-feature.adoc[Adding a Feature] for the full\nchecklist.\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/forms-and-fields.adoc","title":"Forms, Fields and Validation","description":"The dynamic field component, config-driven form construction, the validator set, address data and error-message resolution.","url":"https://docx.payability.com/supplier-client/architecture/forms-and-fields.html","navOrder":5,"source":"= Forms, Fields and Validation\n:description: The dynamic field component, config-driven form construction, the validator set, address data and error-message resolution.\n\nEvery form in the application is a reactive form. There is no template-driven\nform and no third-party form library. Three pieces do the work:\n\n[cols=\"2,3\"]\n|===\n| `DynamicFieldComponent` | One input component that renders as text, number, password, textarea, select, email, date or masked input.\n| `FormHelpersService` | Builds `FormGroup` s from a list of field names, and resolves validation errors to translated strings.\n| `PybValidators` / `PybAsyncValidators` | The two custom validators the app adds on top of Angular's.\n|===\n\n== `DynamicFieldComponent`\n\n`views/partials/components/fields/dynamic-field/`. Selector `pyb-dynamic-field`.\n\nIt is a full custom form control — it implements both `ControlValueAccessor` and\nMaterial's `MatFormFieldControl`, and registers itself four times:\n\n[source,typescript]\n----\nproviders: [\n    { provide: MatFormFieldControl, useExisting: DynamicFieldComponent, multi: true },\n    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DynamicFieldComponent), multi: true },\n    { provide: NG_VALIDATORS, useExisting: DynamicFieldComponent, multi: true },\n    { provide: MAT_DATE_FORMATS, useValue: DATE_FORMATS },\n]\n----\n\nSo it can be dropped inside a `mat-form-field`, bound with `formControlName`, and\nused with a datepicker that renders `MM/dd/yyyy` — without the host form knowing\nwhich input type will appear.\n\n=== Field types\n\n[source,typescript]\n----\nexport enum DynamicFieldType {\n    Text     = 'text',\n    Number   = 'number',\n    Password = 'password',\n    TextArea = 'textarea',\n    Select   = 'select',\n    Email    = 'email',\n    Date     = 'date',\n    Masked   = 'masked',\n}\n----\n\n=== Configuration\n\n[source,typescript]\n----\nexport interface DynamicFieldConfig {\n    type?: DynamicFieldType;\n    options?: SelectOption[];      // { value, label, divider? }\n    textMask?: MaskConfig;         // { mask, placeholderChar }\n}\n----\n\n`type` and `options` can be passed either as their own `@Input()` or inside\n`config`. `ngOnInit` merges them with `lodash.defaultsDeep`, so a `config` value\nwins over the individual input, which wins over the static default:\n\n[source,typescript]\n----\nDynamicFieldComponent.defaultOptions = {\n    type: DynamicFieldType.Text,\n    options: [],\n    textMask: { mask: [], placeholderChar: '\\u2000' },\n};\n----\n\nThe default placeholder character is `U+2000` (en quad), not a space or an\nunderscore — a mask position renders as invisible fixed-width whitespace.\n\n[NOTE]\n.Changing `type` at runtime clears the value\n====\n[source,typescript]\n----\nngOnChanges(changes: SimpleChanges): void {\n    if (changes.type && !changes.type.isFirstChange()) {\n        this.value = null;\n        this.onChange(this.value);\n        this.changed.next(this.value);\n    }\n    // …\n}\n----\n\nThis is deliberate — switching a field from, say, `Text` to `Date` would\notherwise leave an unparseable value in the control. It also means a `type` bound\nto an expression that re-evaluates will silently wipe user input.\n====\n\n=== Internal form group\n\nEach instance builds its own single-control `FormGroup` keyed by the\n`formControlName` it was given, and proxies `value` through it:\n\n[source,typescript]\n----\nget value(): any {\n    return this.formGroup.value[this.formControlName];\n}\n----\n\n`formControlName` is therefore required even when the component is used outside a\nparent form — it is the key of the internal group. `errorState` is read from the\n*outer* `NgControl`, which the component captures in its `validate()` hook.\n\nA `sandbox/` sub-folder holds `DynamicFieldSandboxComponent`, a development\nharness showing every type; it is not routed in production.\n\n== Config-driven form construction\n\n`FormHelpersService.formCreator()` is a curried builder: the first call fixes the\ndefault value and default validators, the second builds the group.\n\n[source,typescript]\n----\nformCreator(defaultValue = '', defaultValidator = []) {\n    return (formNames, formValues = {}, validators = {}, asyncValidators?) => {\n        const controlsConfig = {};\n        formNames.forEach(formName => {\n            controlsConfig[formName] = [\n                get(formValues, formName, defaultValue),\n                get(validators, formName, defaultValidator),\n                get(asyncValidators, formName),\n            ];\n        });\n        return this.fb.group(controlsConfig);\n    };\n}\n----\n\nThe typical use — from `TransferMethodDetailsModal` — makes every field required\nby default and then opts individual fields out:\n\n[source,typescript]\n----\nconst formCreator = this.formHelper.formCreator(null, [Validators.required]);   // <1>\nconst fieldNames  = [\n    ...this.bankInformationFieldsConfig.map(this.extractFieldName),\n    ...this.addressFieldsConfig.map(this.extractFieldName),\n];\nconst validators      = { [AvailableFields.AddressLine2]: [] };                // <2>\nconst asyncValidators = { [AvailableFields.RoutingNumberBankID]: [this.pybAsyncValidators.swift(US_CODE)] };\n\nreturn formCreator(fieldNames, values, validators, asyncValidators);\n----\n<1> Default value `null`, default validator `required`.\n<2> Address line 2 is the only optional field, expressed as an empty override\n    array rather than by omission.\n\nField names come from the `AvailableFields` enum\n(`views/pages/settings/form-fields.enum.ts`), which is the canonical list of\ncontrol names shared by the settings pages, the transfer-information modals and\nthe onboarding forms:\n\n[cols=\"1,3\"]\n|===\n| Business | `companyName`, `website`, `legalEntityType`, `startDate`, `ein`, `stateOfIncorporation`\n| Person | `firstName`, `lastName`, `dateOfBirth`, `taxId`, `ssn`, `phone`, `email`\n| Address | `addressLine1`, `addressLine2`, `country`, `city`, `state`, `zip`, `postalCode`\n| Banking | `accountNumber`, `routingNumber`, `routingNumberBankId`, `beneficiaryName`, `bankName`\n|===\n\n[WARNING]\n====\n`AvailableFields` lives under `views/pages/settings/`, but `FormHelpersService`\nin `core/` imports it with a relative path (`../../../views/pages/settings/…`) —\none of the upward dependencies described in\nxref:architecture/conventions.adoc[Coding Conventions]. Treat the enum as shared\nvocabulary rather than settings-page detail, and do not add page-specific fields\nto it.\n====\n\n== Validators\n\n=== `PybValidators`\n\nA single synchronous validator, exposed as a static:\n\n[source,typescript]\n----\nexport const patternValidator = (pattern: string | RegExp): ValidatorFn => {\n    return (control: AbstractControl): ValidationErrors => {\n        const newPattern = new RegExp(pattern);\n        const value = control?.value;\n        if (!isNull(value) && !isUndefined(value) && !isEmpty(value)) {\n            return newPattern.test(value) ? null : { pattern: true };\n        }\n        return null;\n    };\n};\n----\n\nUnlike `Validators.pattern`, it accepts a `string` and does not anchor it, and it\ntreats empty values as valid — so it composes with `Validators.required` instead\nof duplicating it. Used by the marketplace `invite-step` for the NewEgg\ncredential and password rules.\n\n=== `PybAsyncValidators`\n\n`swift(countryCode)` verifies a routing/SWIFT code against the backend and\nsurfaces the server's own message as the error text:\n\n[source,typescript]\n----\nswift(countryCode: string): AsyncValidatorFn {\n    return (control: AbstractControl): Observable<ValidationErrors | null> => {\n        if (!countryCode || !control.value) {\n            return of(null);\n        }\n        return this.swiftService.verifyRouting(countryCode, control.value).pipe(\n            map(() => null),\n            catchError(({ error }) => of({ swift: error.status.user_message })),\n        );\n    };\n}\n----\n\nThe error payload is a *human-readable string from the API*, not a key, so it is\ndisplayed verbatim and is not translated.\n\n=== `phoneValidator`\n\nOn `FormHelpersService` rather than the validator classes, because it needs to\nreach across the form group. It combines a phone control with a country control\nand validates with `google-libphonenumber`:\n\n[source,typescript]\n----\nphoneValidator(phoneNumberControlName: string, countryControlName: string): ValidatorFn {\n    return (control: AbstractControl): ValidationErrors | null => {\n        if (!control.parent) {\n            return null;\n        }\n        const phoneNumber = control.parent?.get(phoneNumberControlName)?.value;\n        const country     = control.parent?.get(countryControlName)?.value;\n\n        let validNumber = false;\n        try {\n            const number = this.phoneNumberUtil.parse(`+${country.phoneCode}${phoneNumber}`);\n            validNumber = this.phoneNumberUtil.isValidNumber(number);\n        } catch (e) {}\n\n        return validNumber ? null : { wrongNumber: true };\n    };\n}\n----\n\nThe `catch` is empty by design: any parse failure means invalid, reported as\n`{ wrongNumber: true }`. The country control must hold an object with a\n`phoneCode`, not a plain ISO string.\n\n== Error messages\n\n`hasError()` is the standard \"should I show an error\" check — invalid *and*\ntouched, so errors do not appear before the user has interacted:\n\n[source,typescript]\n----\nhasError(formGroup: FormGroup, formControlName: string): boolean {\n    const formControl = formGroup.controls[formControlName];\n    return formControl.invalid && formControl.touched;\n}\n----\n\n`getErrorMessages()` maps Angular's error keys to translation keys. Only one\ndefault is built in:\n\n[source,typescript]\n----\nprivate readonly defaultErrorMessages: Dictionary<string> = {\n    required: 'COMMON.VALIDATION.REQUIRED',\n};\n----\n\nA caller passes `errorsMap` for the rest. When a key has no mapping and\n`controlDefaultError` is true, the *raw error value* is passed to\n`translateService.instant()` — which is how the `swift` validator's server\nmessage reaches the screen: `instant()` returns an unknown key unchanged.\n\n== Address data\n\nCountry and state options do not come from the API. They are compiled into the\nbundle at `src/assets/js/global/address-field/`, and read through\n`AddressFieldService`:\n\n[cols=\"2,3\"]\n|===\n| `getCountriesDictionary(order?)` | All countries as `SelectOption[]`. With `order`, those ISO codes are hoisted to the top and a `divider: true` entry is inserted before the remainder.\n| `getStatesDictionary(iso)` | Administrative areas for a country, or `null` if it has none.\n| `getCountryNameByCountryCode(iso)` | Display name, falling back to the code itself.\n| `getStateNameByCountryCodeAndStateCode()` | Display name for a state.\n| `getStateCodeByCountryCodeAndName()` | Reverse lookup, by label.\n|===\n\n`getStatesDictionary()` walks a nested `locality` → `administrativearea` →\n`options` structure and returns `null` from a bare `catch` if the shape does not\nmatch. A country with no states is therefore indistinguishable from a malformed\nentry — both yield `null`, and callers render an empty select.\n\nThe `countries` NgRX entity collection is a separate concern: it holds the\nAPI's country list used for tax and compliance. See\nxref:state/entity-slices.adoc[Entity Slices].\n\n== `simple-address-form`\n\n`views/templates/simple-address-form/` is the reusable address block. It emits its\nown `FormGroup` upward rather than accepting one:\n\n[source,typescript]\n----\n@Output() onFormReady = new EventEmitter<{ formGroup: FormGroup }>();\n@Output() changed     = new EventEmitter();\n----\n\nThe host stores the group from `onFormReady` and calls `checkIfFormIsValid()`\nbefore submitting, which marks everything touched so errors become visible.\n\nValidation is country-dependent — city and state are required only for the three\ncountries that have structured data for them:\n\n[source,typescript]\n----\nupdateValidity(countryCode: string) {\n    const { localityName, administrativeArea } = this.addressForm.controls;\n    if (['US', 'GB', 'CA'].includes(countryCode)) {\n        localityName.setValidators([Validators.required]);\n        administrativeArea.setValidators([Validators.required]);\n    } else {\n        localityName.clearValidators();\n        administrativeArea.clearValidators();\n    }\n    localityName.updateValueAndValidity();\n    administrativeArea.updateValueAndValidity();\n    this.addressForm.updateValueAndValidity();\n}\n----\n\n`['US', 'GB', 'CA']` appears twice — once here and once in `fetchCountries()` as\nthe hoist order for the country select. Both are inline literals.\n\n[WARNING]\n.This template does not use `AvailableFields`\n====\nIts controls are `addressLine1`, `addressLine2`, `country`, `administrativeArea`,\n`localityName`, `postalCode` — Google-style address names, whereas\n`AvailableFields` uses `state`, `city` and `zip`. Anything consuming both has to\nmap between the two vocabularies.\n====\n\n`changed` is debounced by one second, so a host that persists on change writes at\nmost once per second per address.\n\n== Dictionaries\n\n`core/pyb/dictionaries/payment-method.dict.ts` maps `PaymentMethodType` to\ndisplay data — name, long name, sort `priority`, and a `feeText(feeTier)`\nfunction that formats the fee from the fee schedule.\n\n[cols=\"1,3\"]\n|===\n| `ACH` | Bank ACH. `{feePercent}% Fee`, or `$0 Fee` when absent.\n| `WIR` | Bank Wire. `{feePercent}% Fee`, or `$8 Fee` when absent.\n| `PPM` | Instant Transfer. Percentage plus an optional minimum; has a separate `trailDescription` for the 7-day free trial.\n| `PCARD` | Prepaid card. Name is the literal `'Pcard name to remove'` — never displayed, because card names are resolved by the card components.\n|===\n\nThe hardcoded `$8` and `$0` fallbacks are display defaults for a missing fee tier,\nnot authoritative pricing. See xref:business/instant-transfer.adoc[Instant Transfer].\n\n== Conventions\n\n* Build groups with `FormBuilder`, never with `new FormGroup()` literals in a\n  template.\n* Use `hasError()` + `getErrorMessages()` rather than reading `control.errors` in\n  the template, so error copy stays in `en.ts`.\n* Add a validator to `PybValidators` only if it is generic; anything that reads\n  sibling controls belongs on `FormHelpersService`.\n* Every label, placeholder and error string is a translation key.\n  xref:development/i18n.adoc[Internationalization]\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/flows-and-tasks.adoc","title":"Flows & Tasks Engine","description":"The TasksFlow mini-engine that drives multi-step modal wizards, and the flows built on it.","url":"https://docx.payability.com/supplier-client/architecture/flows-and-tasks.html","navOrder":6,"source":"= Flows & Tasks Engine\n:description: The TasksFlow mini-engine that drives multi-step modal wizards, and the flows built on it.\n\nSeveral business journeys are sequences of modals with branching, back\nnavigation and cancellation. Rather than encoding that in component state, the\napp uses a small orchestration engine in\n`src/app/core/_base/tasks-flow/tasks-flow.ts`.\n\n== Core types\n\n[cols=\"1,3\"]\n|===\n| Type | Role\n\n| `FlowTask`\n| One step. `execute(context?, onDone?)`. Usually opens a modal or calls an API.\n\n| `FlowContext`\n| A `Map`-backed key/value bag shared by every task in the flow. `set()` returns\n  `this` so it chains.\n\n| `FlowTaskResult`\n| `{ type: FlowTaskResultType, data?: D }` — what a task reports back.\n\n| `FlowTaskResultType`\n| `Done` \\| `Cancel` \\| `Terminated` \\| `Error`.\n\n| `TasksFlow`\n| The runner. Holds the ordered task list, the shared context, and the cursor.\n\n| `TaskFlowBuilder`\n| Root-provided fluent builder. `addTask()`, `setContextData()`, `getFlow()`.\n  `getFlow()` hands out the built flow *and resets the builder*, so one injected\n  builder can construct many flows.\n|===\n\n== Control flow\n\n[cols=\"1,3\"]\n|===\n| Call | Behaviour\n\n| `run()` | Executes the task at the current cursor.\n| `runNext()` | Advances the cursor, or emits `Done` to the registered handlers if already at the last task.\n| `runPrevious()` | Moves the cursor back, or emits `Cancel` if already at the first task.\n| `runCurrent()` | Re-executes the current task.\n| `registerOnDone(handler)` | Registers a completion handler. Handlers receive `{ type, data: FlowContext }` and are cleared after firing once.\n|===\n\nThe conventional per-task handler maps a result type onto navigation:\n\n[source,typescript]\n----\nprivate defaultHandler(flow: TasksFlow): FlowTaskDoneHandler {\n    return result => {\n        switch (result.type) {\n            case FlowTaskResultType.Done:       flow.runNext();     break;\n            case FlowTaskResultType.Cancel:     flow.runPrevious(); break;\n            case FlowTaskResultType.Terminated:                     break;\n        }\n    };\n}\n----\n\n`Terminated` deliberately does nothing — it ends the flow in place, which is how\n\"user closed the dialog and we should show a banner instead\" is expressed.\n\n== Task catalog\n\nTasks live in `src/app/core/pyb/tasks/` and are registered by `TasksModule`.\n\n[cols=\"1,3\"]\n|===\n| Task | Purpose\n\n2+| *Advance Line* — `tasks/advance-line/`\n\n| `OpenAdvanceLineInputModalTask` | Collect the draw amount.\n| `OpenAdvanceLineConfirmationModalTask` | Confirm amount, fee and offer type.\n| `OpenAdvanceLineSuccessModalTask` | Success confirmation after a completed draw.\n| `OpenAdvanceDetailsModalTask` | Show terms of an advance offer.\n| `OpenInstantAdvanceDetailsTask` | Show terms of an Instant Advance offer.\n| `OpenAchSetupModalTask` | Collect ACH details before a first draw.\n| `AcceptAdvanceLineTask` | Accept an offer through the API.\n| `DirectAcceptAdvanceLineTask` | Accept without the confirmation step.\n| `StartAdvanceLineAcceptOfferFlowTask` | Entry point that launches the accept-offer flow.\n| `WithdrawAdvanceLineOfferTask` | Call the withdraw endpoint.\n\n2+| *DocuSign* — `tasks/docusign/`\n\n| `DocusignAdvanceLineTask` | Build the redirect URL and leave for DocuSign.\n| `ReSignContractDocusignTask` | Same, for the re-assign-contract flow.\n\n2+| *General* — `tasks/general/`\n\n| `UpdateUserStatusTask` | Dispatch `userStatusUpdateRequested()`.\n| `DeleteNotificationTask` | Remove a dashboard notification once acted on.\n\n2+| *Legal* — `tasks/legal-info-data/`\n\n| `OpenLegalInfoModalTask` | Present legal disclosures for acknowledgement. Note the file is `open-legal-inf-modal.task.ts` — the truncated `inf` is a typo in the filename, not here.\n\n2+| *LTC* — `tasks/ltc/`\n\n| `LtcOptionsTask` | Present available Long Term Contract options.\n| `LtcOptionConfirmationTask` | Confirm the chosen option.\n| `LtcDocuSignTask` | Redirect to DocuSign for the LTC.\n|===\n\n== Composed flows\n\nFlows live in `src/app/core/pyb/flows/` and are registered by `FlowsModule`.\n\n[cols=\"1,3\"]\n|===\n| Flow | Description\n\n| `AdvanceLineWithdrawFlow` | Draw funds from an active Advance Line.\n| `AdvanceLineAcceptOfferFlow` | Accept a new Advance Line offer.\n| `AdvanceLinePendingActivationFlow` | First draw for an expedited, still-pending supplier.\n| `AdvanceLineLegacyActivationFlow` | Activation path for the legacy advance product.\n| `LtcFlowService` | Sign a new Long Term Contract.\n| `LtcRenewalFlowService` | Renew an existing Long Term Contract.\n| `ReSignContractFlow` | Re-sign an updated supplier agreement.\n|===\n\n=== Worked example: `AdvanceLineWithdrawFlow`\n\nThe flow *branches on user state* while it is being built, which is the main\nreason this engine exists.\n\n[source,typescript]\n----\nprivate buildTaskFlow(): TasksFlow {\n    const taskFlow = new TaskFlowBuilder();\n    const { status }: { status: Status } = this.userService.getCurrentUser();\n\n    taskFlow\n        .setContextData('data', this.prepareFlowContextData())\n        .addTask(this.openAdvanceLineInputModalTask, this.defaultHandler)\n        .addTask(this.openAdvanceLineConfirmationModalTask, this.defaultHandler);\n\n    if (!status.instantAdvanceSigned || this.userService.isAprUserContract()) {\n        taskFlow.addTask(this.docusignAdvanceLineTask);          // <1>\n    } else {\n        taskFlow\n            .addTask(this.withdrawAdvanceLineTask, this.defaultHandler)\n            .addTask(this.updateUserStatusTask, this.defaultHandler)\n            .addTask(this.openAdvanceLineSuccessModalTask);      // <2>\n    }\n\n    return taskFlow.getFlow();\n}\n----\n<1> A supplier who has not yet signed, *or* who is on an APR contract, must go\n    through DocuSign. The flow ends here because the browser navigates away; it\n    resumes on return via\n    xref:business/docusign.adoc[`DocusignVerificationService.initializeFlow()`].\n<2> An already-signed, non-APR supplier withdraws inline — no redirect, and the\n    success modal closes the loop in-app.\n\nThe context is seeded with everything downstream tasks need:\n\n[source,typescript]\n----\nprivate prepareFlowContextData() {\n    const user = this.userService.getCurrentUser();\n    const { advanceLine, autodebitPmtCfg, instantAdvanceSigned } = user.status;\n    return {\n        advanceLine,\n        id: user.id,\n        autodebitPmtCfg,\n        docuSignNoteVisible: !instantAdvanceSigned,\n    };\n}\n----\n\n=== Worked example: `ReSignContractFlow` with a fallback banner\n\n`GlobalSubscriptionsService` runs the re-sign flow, and if the supplier\nterminates it, replaces the modal with a persistent banner that can restart it:\n\n[source,typescript]\n----\nprivate runReSignContractFlow() {\n    const banner = reSignContractBannerFactory(() => this.reSignContractFlow.run());\n    this.reSignContractFlow.run(({ type }) => {\n        if (type === FlowTaskResultType.Terminated) {\n            this.bannerService.showBanner(banner);\n        }\n    });\n}\n----\n\n== When to use a flow\n\nUse `TasksFlow` when *all* of the following hold:\n\n* there are three or more sequential steps,\n* a step can send the user backwards,\n* the sequence itself depends on user state, or\n* the sequence is interrupted by an external redirect.\n\nFor a simple confirm-then-act interaction, open a `MatDialog` / `PybDialogService`\ndirectly — a flow is overhead.\n"},{"component":"supplier-client","module":"ROOT","path":"architecture/conventions.adoc","title":"Coding Conventions","description":"Naming, structure, and the rules enforced by lint, format and review.","url":"https://docx.payability.com/supplier-client/architecture/conventions.html","navOrder":7,"source":"= Coding Conventions\n:description: Naming, structure, and the rules enforced by lint, format and review.\n\n== Naming\n\n[cols=\"1,2,2\"]\n|===\n| Element | Rule | Example\n\n| Component selector | `pyb-` prefix, kebab-case | `pyb-advance-line-widget`\n| Component class | Suffix `Component`, `Page`, `View`, `Base` or `Modal` | `SellerCardPage`, `LtcSummaryModalComponent`\n| Service | Suffix `Service`, root-provided | `PermissionsService`\n| Guard | Suffix `Guard` | `DocuSignGuard`\n| Flow | Suffix `Flow` or `FlowService` | `AdvanceLineWithdrawFlow`\n| Task | Suffix `Task` | `WithdrawAdvanceLineOfferTask`\n| Enum | Suffix `Enum` or a plural type name | `PermissionsEnum`, `AdvanceLineEventTypes`\n| RxJS subject field | Suffix `Subject` | `toggleFilterSubject`\n| Observable field | Suffix `$` | `user$`, `currentUser$`\n| Mock | `<Resource><Type>Mock` | `UserServiceMock`, `PermissionsServiceMock`\n| NgRX action | `[Feature] Description` | `[DashboardNotifications] Fetch Dashboard Notification`\n| DI token | `SCREAMING_SNAKE_CASE` + `_TOKEN` | `FEE_REPORT_COLUMNS_CONFIG_TOKEN`\n|===\n\n== File organisation\n\n* One concept per file. A feature slice is a folder with\n  `*.actions.ts`, `*.reducer.ts`, `*.selectors.ts`, `*.effects.ts`, `*.module.ts`.\n* Every component that is used outside its own folder ships its own\n  `*.module.ts` that declares and exports it.\n* Specs sit beside the file they test: `foo.service.ts` / `foo.service.spec.ts`.\n* Barrels (`index.ts`) exist for `core/auth`, `core/pyb/services`,\n  `core/pyb/features`, `core/pyb/entities` and `core/pyb/constants`. Import\n  through the barrel where one exists.\n\n== Imports\n\n[IMPORTANT]\n====\nNever use a relative import across module boundaries. Use the configured path\naliases — see xref:reference/path-aliases.adoc[Path Aliases]. Relative imports\nare acceptable only within the same folder or a direct child.\n====\n\n[source,typescript]\n----\n// good\nimport { PermissionsService } from '@pyb-core/pyb/services/permissions.service';\nimport { WelcomeModalComponent } from '@pyb-modals/welcome-modal/welcome-modal.component';\n\n// bad\nimport { PermissionsService } from '../../../../core/pyb/services/permissions.service';\n----\n\n== Layering\n\nThe intended dependency direction is one-way — `views/` imports from `core/`, not\nthe reverse:\n\n[cols=\"1,3\"]\n|===\n| `core/` | Business logic, state, services, guards. Should know nothing about pages.\n| `views/` | Presentation. Selects from the store, dispatches actions, calls core services.\n| `vendors/` | Third-party wrappers. Imported by `core/`, never the reverse.\n|===\n\n[WARNING]\n.The codebase does not currently hold this line\n====\nAbout 49 files under `core/` import from `views/` — roughly 90 import statements.\nThey cluster into four recognisable patterns:\n\n[cols=\"1,3\"]\n|===\n| Modal components | `core/pyb/tasks/`, `flows/` and effects import concrete modal components and their data interfaces from `@pyb-modals/`, because a task's whole job is to open a dialog.\n| Report types | `core/pyb/datasources/` and `core/reports/` import filter and row interfaces from `views/pages/reports/…`, where the column configs live.\n| Form vocabulary | `FormHelpersService` and `cancellation-survey-modal.config.ts` import `AvailableFields`, `FormFieldConfig` and `DynamicFieldType` from the views layer.\n| Onboarding models | `core/pyb/interfaces/approved-products.interface.ts` imports the three `ProductOffering*Class` models from `views/onboarding/`.\n|===\n\nNothing enforces the direction — there is no lint rule for it. When adding code,\nprefer passing view knowledge in (a DI token, an action payload, a method\nargument) over importing it, and prefer moving a shared *type* into `core/` over\nadding another upward import. Do not treat the existing imports as precedent for\nnew ones.\n====\n\n== Change detection\n\n`ChangeDetectionStrategy.OnPush` is the default for new components. Consequences:\n\n* pass immutable data through `@Input()`,\n* prefer the `async` pipe over manual `subscribe()` in templates,\n* if you must mutate local state outside Angular's zone, call\n  `ChangeDetectorRef.markForCheck()`.\n\n== Subscription hygiene\n\nExtend `Subscriber` (`@pyb-core/pyb/utils/subscriber.class`) and pipe through\n`takeUntil(this.ngUnsubscribe$)`. This applies to services with long-lived\nsubscriptions as well as components.\n\n[source,typescript]\n----\nexport class SomeComponent extends Subscriber implements OnInit {\n    ngOnInit(): void {\n        this.store.select(selectCurrentUser)\n            .pipe(takeUntil(this.ngUnsubscribe$))\n            .subscribe(user => { /* … */ });\n    }\n}\n----\n\n== NgRX rules\n\n* Reducers are pure. `storeFreeze` is active outside production and will throw\n  on any mutation.\n* Side effects — HTTP, `localStorage`, `router.navigate`, dialogs — belong in\n  effects or services, never in reducers.\n* Selectors are memoized with `createSelector` and colocated with their slice.\n* Components select; they never reach into `state` directly.\n\n== Translations\n\nNo user-visible string is hardcoded. Every label goes through `TranslateService`\nor the `translate` pipe, with the key defined in `src/assets/i18n/en.ts`.\nSee xref:development/i18n.adoc[Internationalization].\n\n== Analytics\n\nEvery user-initiated action that matters to the business emits a Segment event\nthrough `EventLoggerService`, using a constant from\n`src/app/core/pyb/enums/event-types.enum.ts`. Ad-hoc string literals are not\nacceptable. See xref:analytics/event-logger.adoc[Event Logger].\n\n== Tooling\n\n[cols=\"1,3\"]\n|===\n| Command | Effect\n\n| `npm run lint` | ESLint over `./src` with `.eslintrc.js`.\n| `npm run lint:fix` | Lint with autofix.\n| `npm run format-all` | Prettier over `src/app/**/*.{ts,js}`.\n| `npm run test:ci` | `lint:quiet` then headless Karma with coverage.\n|===\n\nHusky hooks run `pretty-quick --staged` and `lint-staged` on commit, and the\nunit suite on push.\n\n== Things not to do\n\n[cols=\"1,3\"]\n|===\n| Don't | Because\n\n| Import `CoreModule` from a feature module\n| It is a singleton; a second import duplicates interceptors and providers.\n\n| Edit `src/environments/environment.ts`\n| It is generated by `config/pre-build.js` and overwritten on every build.\n\n| Drop `--max_old_space_size=4096` from an npm script\n| The Angular {angular-version} build runs out of heap without it.\n\n| Mutate NgRX state\n| `storeFreeze` throws in dev and the bug silently corrupts state in prod.\n\n| Add a gated feature without a `PermissionsEnum` entry\n| The permission tree in `permissions.const.ts` is what `ngx-permissions` reads;\n  an unregistered permission is invisible to guards and directives.\n\n| Hardcode a route string\n| `URLS_ROUTER` is the single source of truth.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"routing/route-tree.adoc","title":"Route Tree","description":"Every URL the Supplier Client serves, the module behind it, and the guards protecting it.","url":"https://docx.payability.com/supplier-client/routing/route-tree.html","navOrder":8,"source":"= Route Tree\n:description: Every URL the Supplier Client serves, the module behind it, and the guards protecting it.\n\nRouting is declared in `src/app/app-routing.module.ts` with\n`RouterModule.forRoot(routes, { useHash: true, relativeLinkResolution: 'legacy' })`.\nHash routing means every URL carries a `#`, e.g.\n`https://app.payability.com/#/dashboard`.\n\nRoute strings are centralised in\n`src/app/core/_config/urls-route.const.ts` as the frozen `URLS_ROUTER` object.\n\n== Top level\n\n[cols=\"2,2,2\"]\n|===\n| Path | Target | Guards\n\n| `/auth/**`\n| `AuthModule` (lazy)\n| —\n\n| `/login/**`\n| `AuthModule` (lazy)\n| —\n\n| `/login/confirm/email/:emailcode`\n| redirect → `manage-account/two-factor/confirm/:emailcode`\n| —\n\n| `/` (shell)\n| `BaseComponent`\n| `AuthGuard`\n\n| `/**` (unmatched, outside the shell)\n| redirect → `error/403`\n| —\n|===\n\n== Auth module (`/auth`, `/login`)\n\n[cols=\"2,2,2\"]\n|===\n| Path | Component | Guards\n\n| `` (empty) | redirect → `login` | —\n| `login` | `LoginComponent` | `LogOutGuard`\n| `auth` | `LoginAsSupplierComponent` | —\n| `register` | `RegisterComponent` | `LogOutGuard`\n| `reset-password` | `RecoverPasswordComponent` | `LogOutGuard`\n| `new-password` | `NewPasswordComponent` | `NewPasswordTokenGuard`\n|===\n\n`login` carries `data: { returnUrl: window.location.pathname }` so the user is\nreturned to where they were after signing in.\n\n`auth` (`LoginAsSupplierComponent`) is the internal *impersonation* entry point —\nstaff arrive with a token and are dropped straight into the supplier's session.\n\n== Authenticated shell (`BaseComponent`, `AuthGuard`)\n\n[cols=\"2,2,2\"]\n|===\n| Path | Module | Guards / data\n\n| `dashboard` | `DashboardModule` | `DocuSignGuard`\n| `daily-summary-report` | `DailySummaryModule` | `NgxPermissionsGuard`, `permissions.only: ['REPORTS.DAILY_SUMMARY']`\n| `my-marketplaces` | `MyMarketplacesPageModule` | —\n| `sales-call-summary` | `SalesCallSummaryModule` | —\n| `seller-card` | `SellerCardModule` | —\n| `newcard` | `NewCardModule` | —\n| `vendor-pay` | `VendorPayIncomingReportsModule` | —\n| `send-money` | `SendMoneyModule` | —\n| `reports` | `ReportsModule` | —\n| `manage-account` | `ManageAccountModule` | —\n| `partners` | `PartnersModule` | —\n| `product-offerings` | `ProductOfferingsModule` | `DocuSignGuard`\n| `referral` | `MoreFromPayabilityModule` | —\n| `help-center` | `DashboardComponent` (never rendered) | `HelpCenterRedirectGuard`\n| `payshield-subscription` | `PayshieldSubscriptionModule` | —\n| `blog` | `BlogPageModule` | —\n| `onboarding` | `OnboardingModule` | —\n| `` (empty) | redirect → `dashboard` | —\n| `**` | redirect → `dashboard` | —\n|===\n\n[NOTE]\n====\n`help-center` declares `DashboardComponent` purely to satisfy the router's\nrequirement for a target. `HelpCenterRedirectGuard` navigates away and returns\n`false`, so the component is never instantiated.\n====\n\n== Reports (`/reports`)\n\nDeclared in `src/app/views/pages/reports/reports-routing.module.ts` under the\n`ReportsComponent` shell.\n\n[cols=\"2,3\"]\n|===\n| Path | Module / component\n\n| `/reports/card-transactions` | `CardTransactionsPageComponent` (eager within the module)\n| `/reports/transfer` | `TransferReportPageModule`\n| `/reports/marketplace-transaction-details` | `MarketplaceTransactionPageModule`\n| `/reports/instant-advance-report` | `InstantAdvanceReportPageModule`\n| `/reports/reconciliation-view` | `ReconciliationViewReportModule`\n| `/reports/fee-report` | `FeeReportPageModule`\n| `/reports/outstanding-balance` | `OutstandingBalancePageModule`\n| `/reports/vendor-pay-report` | `VendorpayReportsModule`\n| `/reports/marketplace-payment-report` | `MarketplacePaymentPageModule`\n| `/reports/daily-summary-report` | `DailySummaryModule`\n| `/reports/contracts-overview-report` | `ContractsOverviewPageModule`\n|===\n\n[NOTE]\n====\nThe Daily Summary report is reachable at two URLs. `/reports/daily-summary-report`\nsits inside the reports shell; `/daily-summary-report` is a top-level route\nadditionally gated by `NgxPermissionsGuard` on `REPORTS.DAILY_SUMMARY`.\n====\n\n== Manage Account (`/manage-account`)\n\nDeclared in `src/app/views/pages/manage-account/manage-account.module.ts` under\n`ManageAccountComponent`. The parent route applies:\n\n[source,typescript]\n----\nconst force2FAConfirmationGuard = {\n    canActivate: [TwoFactorAuthenticationGuard],\n    data: { force2FAConfirmation: true },\n};\n----\n\nChild paths come from `manage-account.config.ts` (`manageAccountRoutes`):\n\n[cols=\"2,3\"]\n|===\n| Path | Module\n\n| `` (empty, `pathMatch: 'full'`) | `YourInformationModule`\n| `your-information` | `YourInformationModule`\n| `your-information/upload-id` | `UploadIdModule`\n| `business-information` | `BusinessInformationModule`\n| `transfer-information` | `TransferInformationModule` (reused from `pages/settings/`)\n| `security` | `SecurityModule`\n| `security/change-password` | `ChangePasswordModule`\n| `security/two-factor` | `TwoFactorModule`\n|===\n\nA sibling route `two-factor/confirm/:emailcode` renders `ManageAccountComponent`\ndirectly. It is the landing target for the email-confirmation link redirected\nfrom `/login/confirm/email/:emailcode`.\n\n== Onboarding (`/onboarding`)\n\nDeclared in `src/app/views/onboarding/onboarding.module.ts` under\n`OnboardingComponent`, which routes the supplier to the correct step based on\n`dashboardStatus`.\n\n[cols=\"2,3\"]\n|===\n| Path | Module\n\n| `repayment-information` | `RepaymentInformationModule`\n| `underwriting-review` | `UnderwritingReviewModule`\n| `product-offering` | `ProductOfferingModule`\n| `sign-agreement` | `SignAgreementModule`\n|===\n\nSee xref:business/onboarding.adoc[Onboarding].\n\n== Seller Card (`/seller-card`)\n\n[cols=\"2,2,2\"]\n|===\n| Path | Component | Guard\n\n| `` | `SellerCardPage` → `SellerCardListComponent` | `SellerCardListGuard`\n|===\n\n`ActiveUserGuard` is also defined in `pages/seller-card/guards/` for card\noperations that require an active supplier.\n\n== `URLS_ROUTER` reference\n\n[source,typescript]\n----\nexport const URLS_ROUTER = {\n    AUTH:            { LOGIN: 'auth/login' },\n    DASHBOARDS:      { ROOT: 'dashboard' },\n    PRODUCT_OFFERINGS: { ROOT: 'product-offerings' },\n    UNSUPPORTED:     { ROOT: 'unsupported' },\n    SELLER_CARD:     { ROOT: 'seller-card' },\n    NEW_CARD:        { ROOT: 'newcard' },\n    MARKETPLACE:     { ROOT: 'marketplaces', MY_MARKETPLACES: 'my-marketplaces' },\n    REPORTS: {\n        ROOT: 'reports',\n        DAILY_SUMMARY_REPORT: 'reports/daily-summary-report',\n        INSTANT_ADVANCE_REPORT: 'reports/instant-advance-report',\n        TRANSFER: 'reports/transfer',\n        MARKETPLACE_PAYMENT_REPORT: 'reports/marketplace-payment-report',\n        MARKETPLACE_TRANSACTION_DETAILS: 'reports/marketplace-transaction-details',\n        RECONCILIATION_VIEW: 'reports/reconciliation-view',\n        FEE_REPORT: 'reports/fee-report',\n        OUTSTANDING_BALANCE: 'reports/outstanding-balance',\n        CARD_TRANSACTIONS: 'reports/card-transactions',\n        CONTRACTS_OVERVIEW_REPORT: 'reports/contracts-overview-report',\n        VENDOR_PAY_INCOMING: 'vendor-pay',\n        VENDOR_PAY: 'reports/vendor-pay-report',\n    },\n    SETTINGS: {\n        PERSONAL_INFORMATION: 'settings/personal-information',\n        BUSINESS_INFORMATION: 'settings/business-information',\n        AGREEMENT: 'settings/agreement',\n        TRANSFER_INFORMATION: 'settings/transfer-information',\n        TWO_FACTOR_AUTHENTICATION: 'settings/two-factor-authentication',\n    },\n    HELP_CENTER:     { ROOT: 'help-center' },\n    MANAGE_ACCOUNT:  { ROOT: 'manage-account', TWO_FACTOR: 'manage-account/two-factor' },\n    REFERRAL:        { ROOT: 'referral' },\n    PARTNERS:        { ROOT: 'partners' },\n    PAYSHIELD_SUBSCRIPTION: { ROOT: 'payshield-subscription' },\n    BLOG:            { ROOT: 'blog' },\n} as const;\n----\n\n== Navigation menu\n\n`MenuConfig` (`src/app/core/_config/menu.config.ts`) declares the header menu\ntree. Each item carries a `page` (from `URLS_ROUTER`), a `translate` key and an\noptional `permission` string. Items whose permission the user lacks are hidden,\nso the menu and the guards are driven by the same permission names.\n"},{"component":"supplier-client","module":"ROOT","path":"routing/guards.adoc","title":"Route Guards","description":"Every guard in the application, what it actually checks, and where it redirects.","url":"https://docx.payability.com/supplier-client/routing/guards.html","navOrder":9,"source":"= Route Guards\n:description: Every guard in the application, what it actually checks, and where it redirects.\n\n== Summary\n\n[cols=\"2,2,3\"]\n|===\n| Guard | Location | Applied to\n\n| `AuthGuard` | `core/auth/_guards/auth.guard.ts` | The authenticated shell (`BaseComponent`)\n| `DashboardStatusGuard` | `core/auth/_guards/auth.guard.ts` | Available for dashboard-only routes\n| `LogOutGuard` | `core/auth/_guards/log-out.guard.ts` | `/auth/login`, `/auth/register`, `/auth/reset-password`\n| `NewPasswordTokenGuard` | `core/auth/_guards/new-password-token.guard.ts` | `/auth/new-password`\n| `ModuleGuard` | `core/auth/_guards/module.guard.ts` | Module-level permission gating\n| `DocuSignGuard` | `core/pyb/guards/docu-sign.guard.ts` | `/dashboard`, `/product-offerings`\n| `HelpCenterRedirectGuard` | `core/pyb/guards/help-center-redirect.guard.ts` | `/help-center`\n| `TwoFactorAuthenticationGuard` | `core/pyb/guards/two-factor-authentication.guard.ts` | `/manage-account`\n| `TwoFactorInfoLoadedGuard` | `core/pyb/guards/two-factor-info-loaded.guard.ts` | 2FA sub-routes\n| `NgxPermissionsGuard` | `ngx-permissions` | `/daily-summary-report`\n| `SellerCardListGuard` | `views/pages/seller-card/guards/` | `/seller-card`\n| `ActiveUserGuard` | `views/pages/seller-card/guards/` | Card operations requiring an active supplier\n|===\n\n== `AuthGuard`\n\nSelects `isLoggedIn` from the store. When false it navigates to\n`auth/login?returnUrl=<current url>` and the observable emits `false`.\n\n[source,typescript]\n----\ncanActivate(route, state): Observable<boolean> {\n    return this.store.pipe(\n        select(isLoggedIn),\n        tap(loggedIn => {\n            if (!loggedIn) {\n                this.router.navigateByUrl(\n                    `${URLS_ROUTER.AUTH.LOGIN}?returnUrl=${state.url}`,\n                ).then();\n            }\n        }),\n    );\n}\n----\n\n[NOTE]\n====\nThe guard reads store state, not `localStorage`. The auth slice is rehydrated\nfrom `localStorage` by the persistent meta-reducer *before* the router runs, so\na page refresh keeps the session. See\nxref:state/persistence.adoc[State Persistence].\n====\n\n== `DashboardStatusGuard`\n\nDeclared alongside `AuthGuard`. It checks whether\n`user.dashboardStatus.status !== SupplierDashboardStatusType.Onboarding` and, if\nthe supplier *is* onboarding, navigates to `/dashboard`.\n\n[WARNING]\n====\nThe guard returns `true` unconditionally — the redirect is a side effect, not a\nblock. It nudges rather than prevents.\n====\n\n== `LogOutGuard`\n\nThe inverse of `AuthGuard`. Returns `true` when the user is logged out, otherwise\nreturns a `UrlTree` for `/auth/login`, keeping an authenticated user off the\nlogin and registration screens.\n\n== `NewPasswordTokenGuard`\n\nGuards `/auth/new-password`. The reset code arrives as the `r` query parameter.\n\n. If a session is already active, dispatch `logout()` and allow navigation.\n. Otherwise call `AuthService.confirmPasswordReset(verificationCode)`.\n. On error, show a `BannerTypes.WARNING` banner with the translated message and\n  return a `UrlTree` for `/auth/login`.\n. On success, return `true`.\n\n== `ModuleGuard`\n\nReads `route.data.moduleName`, registers it with `NgxPermissionsService` and\nthen checks it. Without a `moduleName` in route data it returns `false`.\nProvided by `AppRoutingModule` and `ReportsRoutingModule`.\n\n== `DocuSignGuard`\n\n[IMPORTANT]\n====\nDespite the name, this guard does not check whether a contract is signed. It is\nthe *DocuSign return handler*: it detects a redirect back from DocuSign and runs\nthe corresponding business flow before the page renders.\n====\n\nApplied to `/dashboard` and `/product-offerings` — the two pages DocuSign is\nconfigured to return to.\n\n[source,typescript]\n----\ncanActivate(route, state): Observable<UrlTree | boolean> {\n    return this.store.select(selectCurrentUserId).pipe(\n        filter<string>(Boolean),\n        exhaustMap(supplierKey => {\n            if (this.docusignFlowInitialized(route.queryParams)) {   // <1>\n                return this.runDocuSignVerificationFlow({\n                    flowType: route.queryParams.flowType,\n                    params: {\n                        data: route.queryParams,\n                        event: route.queryParams.event,\n                        supplierKey,\n                    },\n                }).pipe(\n                    catchError(() => of(false)),\n                    map(() => this.getUrlTree(route.queryParams?.mode)),  // <2>\n                    take(1),\n                );\n            }\n            return of(true);                                          // <3>\n        }),\n        take(1),\n    );\n}\n----\n<1> \"Initialized\" here means *both* `event` and `flowType` query parameters are\n    present — the signature of a DocuSign redirect.\n<2> After the flow completes, redirect to a clean URL so the query parameters\n    cannot be replayed. `mode === ProductOfferingsList` → `/product-offerings`,\n    anything else → `/default`.\n<3> Ordinary navigation with no DocuSign parameters passes straight through.\n\nThe flow itself is dispatched by\nxref:business/docusign.adoc[`DocusignVerificationService.initializeFlow()`],\nwhich applies the second, `localStorage`-based replay guard.\n\n== `HelpCenterRedirectGuard`\n\nA navigation-only guard. It tracks\n`RouterNavigationEvents.HelpCenterLinkClicked` with the current user id, opens\n`https://help.payability.com/hc/en-us` in a new tab, and returns `false` so\nAngular never completes the navigation.\n\n[source,typescript]\n----\nstatic helpCenterUrl = 'https://help.payability.com/hc/en-us';\n\ncanActivate(): boolean {\n    this.eventLoggerService.track({\n        additionalProperties: { userId: this.userService.getCurrentUserId() },\n        eventMessage: RouterNavigationEvents.HelpCenterLinkClicked,\n    });\n    window.open(HelpCenterRedirectGuard.helpCenterUrl, '_blank');\n    return false;\n}\n----\n\n== `TwoFactorAuthenticationGuard`\n\nImplements `CanActivate`, `CanActivateChild` and `CanLoad`. Delegates to\n`TwoFactorAuthenticationService.checkTwoFactorAuthenticationState(force)`, where\n`force` comes from `route.data.force2FAConfirmation`.\n\n* A `skipTwoFactorCheck` query parameter bypasses the check entirely.\n* If the check fails *and* `RouteUrlHelperService.prevUrl` is empty — i.e. this\n  was a direct entry rather than in-app navigation — it returns a `UrlTree` for\n  `/dashboard` rather than leaving the user on a dead route.\n\n`ManageAccountModule` applies it with `force2FAConfirmation: true`, so account\nsettings always demand a confirmed second factor.\n\n== `TwoFactorInfoLoadedGuard`\n\nA data-readiness guard. It dispatches `fetch2FAInfo()` and waits for the\n`loaded2FAInfo` action before allowing activation, so 2FA screens never render\nagainst an empty slice.\n\n== `NgxPermissionsGuard`\n\nFrom `ngx-permissions`. Used on the top-level `/daily-summary-report` route:\n\n[source,typescript]\n----\n{\n    path: 'daily-summary-report',\n    loadChildren: () => import('.../daily-summary-page.module').then(m => m.DailySummaryModule),\n    canActivate: [NgxPermissionsGuard],\n    data: { permissions: { only: ['REPORTS.DAILY_SUMMARY'] } },\n}\n----\n\nThe dotted string is the hierarchical permission name built by\n`permissions.const.ts` and loaded into `NgxPermissionsService` by\n`GlobalSubscriptionsService`. See xref:security/permissions.adoc[Permissions System].\n\n== `SellerCardListGuard`\n\nEnsures the Seller Card list has something to show. It dispatches\n`loadPaymentMethods()` and `loadAirwallexCards()`, waits until both slices report\nloaded and not loading, then:\n\n* if the supplier has p-card payment methods *or* any Airwallex card → `true`;\n* otherwise → `UrlTree` for `newcard`, the card-ordering funnel.\n\n== `ActiveUserGuard`\n\nSelects `selectIsSupplierActive`, waits for a boolean, and redirects to\n`/newcard` when the supplier is not `Active`.\n"},{"component":"supplier-client","module":"ROOT","path":"security/authentication.adoc","title":"Authentication","description":"JWT session handling, the login pipeline, AuthService, and the auth NgRX slice.","url":"https://docx.payability.com/supplier-client/security/authentication.html","navOrder":10,"source":"= Authentication\n:description: JWT session handling, the login pipeline, AuthService, and the auth NgRX slice.\n\nThe authentication subsystem lives entirely in `src/app/core/auth/` and is\nregistered by `AuthModule.forRoot()`.\n\n== Login pipeline\n\n[listing]\n----\nLoginComponent submits { email, password }\n  → reCAPTCHA v3 executes and yields a token\n  → AuthService.login(email, password, recaptchaToken)\n        GET {authUrl}/token?timezon=<IANA tz>\n        headers: Authorization: Basic base64(email:password)\n                 recaptcha:     <token>\n     ← { token }\n  → dispatch login({ authToken })\n  → AuthEffects.login$      : localStorage[authTokenKey] = authToken\n                              → userRequested()\n  → AuthEffects.loadUser$   : AuthService.getUserByToken()\n                                 GET supplier details  (SupplierDto)\n                                 GET supplier status   (SupplierStatusDto)\n                              → EventLoggerService.identify(user)\n                              → userLoaded({ user })\n                              → userPermissionsRequested()\n                              → userDashboardStatusRequested({ user })\n                              → loadBalance()\n  → AuthEffects.loadedUserPermissions$\n                            : PermissionsService.getUserPermissions(user)\n                              → userPermissionsLoaded({ permissions })\n  → GlobalSubscriptionsService mirrors permissions into NgxPermissionsService\n  → Router activates the requested route\n----\n\n[NOTE]\n====\nThe `timezon` query parameter is spelled that way in the API contract. The value\ncomes from `Intl.DateTimeFormat().resolvedOptions().timeZone`, captured in the\n`AuthService` constructor.\n====\n\n== Session restoration\n\n`AuthEffects.init$` runs once at startup:\n\n[source,typescript]\n----\ninit$ = createEffect(() =>\n    defer(() => {\n        const authToken = localStorage.getItem(this.env.authTokenKey);\n        return authToken ? of(AuthActions.login({ authToken })) : of(noAction());\n    }),\n);\n----\n\nA stored token re-enters the same pipeline as a fresh login, so user data and\npermissions are always recomputed rather than trusted from persisted state.\n\n== Logout\n\n[source,typescript]\n----\nlogout$ = createEffect(() =>\n    this.actions$.pipe(\n        ofType(AuthActions.logout),\n        tap(() => {\n            this.stateManager.clear();                        // <1>\n            localStorage.removeItem(this.env.authTokenKey);   // <2>\n            setTimeout(() => location.reload(), 1000);        // <3>\n        }),\n    ),\n    { dispatch: false },\n);\n----\n<1> `PersistentStateManager.clear()` wipes persisted slices.\n<2> The session token is removed.\n<3> A full page reload after 1 s guarantees no in-memory state survives — cheaper\n    and safer than resetting every slice by hand.\n\n== `AuthService`\n\n`src/app/core/auth/_services/auth.service.ts`\n\n=== Session\n\n[cols=\"2,3\"]\n|===\n| Method | Behaviour\n\n| `login(email, password, recaptchaToken)`\n| `GET {authUrl}/token` with Basic auth and a `recaptcha` header. Returns `{ token }`.\n\n| `logout()`\n| `POST {authUrl}/logout`.\n\n| `getAccessToken()`\n| `Observable<string>` of `localStorage[authTokenKey]`.\n\n| `setAccessToken(token)` / `removeAccessToken()`\n| Write / delete the token.\n\n| `getHeaders(token)`\n| The header set applied to every authenticated request — see\n  xref:security/interceptors.adoc[HTTP Interceptors].\n|===\n\n=== User loading and mapping\n\n[cols=\"2,3\"]\n|===\n| Method | Behaviour\n\n| `getUserByToken()`\n| `SuppliersService.getSupplierDetails()` → `prepareUser()`, then\n  `getSupplierStatus(user.id)` and copies every matching key onto `user.status`.\n\n| `prepareUser(supplier, token)`\n| Maps `SupplierDto` → `User`, including nested personal and business information.\n\n| `prepareBusinessInformation()`, `preparePersonalInfo()`, `prepareAddress()`, `prepareResidentialAddress()`\n| DTO → model mappers for the sub-objects.\n|===\n\n[NOTE]\n====\n`prepareUser` sets `user.address = residentialAddress`, duplicating\n`user._personalInformation.residentialAddress`. The flat `address`, `firstName`,\n`lastName`, `fullname`, `companyName`, `phone` and `occupation` fields on `User`\nare marked `@Deprecated` — read the `_personalInformation` /\n`_businessInformation` objects in new code.\n====\n\n=== Registration and password reset\n\n[cols=\"2,3\"]\n|===\n| Method | Endpoint\n\n| `register(user)`\n| `SuppliersService.createSupplierAccount({ firstName, lastName, email, phoneNumber, password, companyName })`\n\n| `requestPassword(email, recaptcha)`\n| `POST {apiUrl}/start_password_reset` with a `recaptcha` header.\n  Errors resolve to `{ error: AuthErrorsEnum.EmailNotFound }`.\n\n| `confirmPasswordReset(verificationCode)`\n| `+POST {apiUrl}/confirm_password_reset/{code}+`.\n  Errors resolve to `{ error: AuthErrorsEnum.InvalidVerificationCode }`.\n\n| `executePasswordChange(newPassword, authToken?)`\n| `POST {apiUrl}/execute_password_change`. When `authToken` is supplied it is\n  sent as both `X-Session-Token` and `Authorization: Basic`.\n  Errors resolve to `{ error: AuthErrorsEnum.Fail }`.\n|===\n\n[WARNING]\n====\n`AuthService.handleError()` swallows HTTP failures, logs to the console, and\nemits a sentinel result object. Callers must inspect `response.error` rather\nthan relying on the observable erroring.\n====\n\n=== Legacy surface\n\n`getAllUsers`, `getUserById`, `createUser`, `updateUser`, `deleteUser`,\n`findUsers` and the whole `Role` CRUD block target `v1/sup_widget` and\n`v1/roles`. They are inherited from the original admin template and are not used\nby supplier-facing flows. `getAllRoles()` returns an empty `BehaviorSubject`.\n\n== Password reset flow\n\n[listing]\n----\n1. /auth/reset-password  → requestPassword(email, recaptcha)\n                           POST /start_password_reset\n2. Supplier receives an email with a link carrying ?r=<verificationCode>\n3. /auth/new-password?r=…  → NewPasswordTokenGuard\n                             POST /confirm_password_reset/<code>\n                             invalid → warning banner + redirect to /auth/login\n4. NewPasswordComponent submits → dispatch newPasswordRequest({ password, verificationCode })\n5. AuthEffects.newUserPasswordRequest$\n       confirmPasswordReset(code)          → { token }\n       executePasswordChange(password, token)\n       → newPasswordRequestSuccess | newPasswordRequestError\n----\n\n== Impersonation — login as supplier\n\n`/auth/auth` renders `LoginAsSupplierComponent`. Internal staff arrive with a\npre-issued session token, which is written straight to `localStorage` and the\nstandard `userRequested` pipeline takes over. The supplier sees the identical\napplication; permissions are calculated from their real user object.\n\n== Auth models\n\n[cols=\"2,3\"]\n|===\n| Model | Contents\n\n| `User` | Identity, `schema`, product availability flags, `status`, `dashboardStatus`, `cancelationStatus`, permissions.\n| `Status` | ~40 supplier state flags returned by `getSupplierStatus`. See xref:business/domain-model.adoc[Domain Model].\n| `SupplierDashboardStatus` | `{ status, enableRejectedNotification }`.\n| `PersonalInformation` | Name, DOB, phone, email, tax id, residential address.\n| `BusinessInformation` | Legal name, business type, incorporation state, owner SSN, EIN, start date, business address.\n| `Address` | `addressLine1/2`, `city`, `state`, `postCode`, `country`.\n| `Permission` | `{ id, name, level, parentId, children }` — the hierarchical permission node.\n| `Role` | Legacy, unused in supplier flows.\n| `Notifications` / `DashboardNotifications` | Dashboard notification payloads.\n| `SocialNetworks` | Legacy template model.\n|===\n\nField-level detail: xref:reference/models.adoc[Model Reference].\n\n== Related pages\n\n* xref:state/auth-slice.adoc[Auth Slice] — actions, reducers, selectors\n* xref:security/interceptors.adoc[HTTP Interceptors]\n* xref:security/two-factor.adoc[Two-Factor Authentication]\n* xref:security/permissions.adoc[Permissions System]\n"},{"component":"supplier-client","module":"ROOT","path":"security/interceptors.adoc","title":"HTTP Interceptors","description":"The three interceptors registered by CoreModule and how to opt out of them.","url":"https://docx.payability.com/supplier-client/security/interceptors.html","navOrder":11,"source":"= HTTP Interceptors\n:description: The three interceptors registered by CoreModule and how to opt out of them.\n\n`CoreModule` registers three `HTTP_INTERCEPTORS`, in this order:\n\n[source,typescript]\n----\nproviders: [\n    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor,             multi: true },\n    { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor,     multi: true },\n    { provide: HTTP_INTERCEPTORS, useClass: HttpErrorHandlerInterceptor, multi: true },\n]\n----\n\nAngular runs them in declaration order on the way out and in reverse on the way\nback, so `HttpErrorHandlerInterceptor` sees the response first.\n\n== `AuthInterceptor`\n\n`src/app/core/auth/_services/auth.interceptor.ts`\n\nAttaches the session token to every outgoing request via\n`AuthService.getHeaders(token)`:\n\n[source,typescript]\n----\npublic getHeaders(token: string) {\n    return {\n        'X-Session-Token': token,\n        'Cache-Control': 'no-cache, no-store, must-revalidate',\n        Pragma: 'no-cache',\n        Expires: '0',\n    };\n}\n----\n\nRequests made before a token exists pass through unmodified. The cache headers\nare deliberate: financial figures must never be served from a browser cache.\n\n[WARNING]\n====\n`intercept()` subscribes to `addToken()` synchronously and assigns the resulting\nhandler to a local variable:\n\n[source,typescript]\n----\nintercept(request, next): Observable<HttpEvent<any>> {\n    let handler;\n    this.addToken(request).subscribe(req => (handler = next.handle(req)));\n    return handler;\n}\n----\n\nThis works only because `getAccessToken()` is `of(localStorage.getItem(...))` —\nsynchronous. Changing the token source to anything asynchronous would return\n`undefined` from `intercept()` and break every request.\n====\n\n== `UnauthorizedInterceptor`\n\nSame file. Watches for `401` responses and ends the session:\n\n. If the request carried the `ignoreUnauthorizedToken` header (exported by\n  `@payability/pyb-ng2-api`), the header is stripped and the 401 is allowed\n  through without logging the user out.\n. Otherwise, if the store reports the user as logged in, `logout()` is dispatched.\n. The error is always re-thrown so callers still see it.\n\nThis is what lets login and token-probe requests fail with a 401 without kicking\nan existing session.\n\n== `HttpErrorHandlerInterceptor`\n\n`src/app/core/pyb/services/http-error-handler/http-error-handler.interceptor.ts`\n\nConverts HTTP failures into user-visible toasts, with several special cases.\n\n=== Handled statuses\n\nBy default a toast is shown for:\n\n[cols=\"1,2\"]\n|===\n| Status | Constant\n\n| 400 | `HttpErrorsEnum.BAD_REQUEST`\n| 403 | `HttpErrorsEnum.FORBIDDEN`\n| 500 | `HttpErrorsEnum.INTERNAL_SERVER_ERROR`\n|===\n\nThe message is the translated `COMMON.ERRORS.SOMETHING_WENT_WRONG`, shown for\n10 s and memoized after the first lookup.\n\n=== Special cases\n\n[cols=\"1,3\"]\n|===\n| Condition | Behaviour\n\n| `401` + `error_code === 'PASSWORD_RESET_REQUIRED'`\n| Decodes the email out of the request's `Authorization: Basic` header and\n  redirects to `auth/reset-password?required=<urlencoded email>`. No toast.\n\n| `401` + `error_code === 'ACCOUNT_LOCKED'`\n| Shows a 30 s danger toast at the top of the screen instructing the supplier to\n  email `support@payability.com`.\n\n| `400` on `/execute_password_change`\n| Shows the server's own `error.message` rather than the generic copy, so\n  password-policy violations are actionable.\n|===\n\n=== Bypassing the interceptor\n\nAdd the `bypassHttpErrorHandlerInterceptor` header to suppress the toast when\nthe caller renders its own error UI. Helpers live in\n`http-error-handler.constants.ts`:\n\n[source,typescript]\n----\nexport const httpErrorBypassKey = 'bypassHttpErrorHandlerInterceptor';\n\n// Suppress the toast for every handled status\nexport const bypassHttpErrorHeader = { [httpErrorBypassKey]: [] };\n\n// Suppress it only for the listed statuses\nexport const bypassHttpErrorHeaderFactory = (errorCodes: number[] = []) => ({\n    [httpErrorBypassKey]: serializeErrorCodes(errorCodes),\n});\n----\n\n[source,typescript]\n----\n// Handle 400 inline, let everything else toast as usual\nthis.http.post(url, body, {\n    headers: bypassHttpErrorHeaderFactory([HttpErrorsEnum.BAD_REQUEST]),\n});\n----\n\nThe header is stripped from the request before it leaves the browser. An empty\ncode list means \"bypass everything\"; a populated list means \"bypass only these\nstatuses\".\n\nThe error is re-thrown in every path, so bypassing changes only whether a toast\nappears — never whether the caller sees the failure.\n"},{"component":"supplier-client","module":"ROOT","path":"security/two-factor.adoc","title":"Two-Factor Authentication","description":"Authy-backed SMS second factor — setup, enforcement, state and enrolment flow.","url":"https://docx.payability.com/supplier-client/security/two-factor.html","navOrder":12,"source":"= Two-Factor Authentication\n:description: Authy-backed SMS second factor — setup, enforcement, state and enrolment flow.\n\nTwo-factor authentication is SMS-based and backed by Authy. It is *enforced*\non the account-management area and can be triggered on demand elsewhere.\n\n== Moving parts\n\n[cols=\"2,3\"]\n|===\n| Piece | Location\n\n| Feature slice | `core/pyb/features/two-factor-authentication/`\n| Orchestrating service | `TwoFactorAuthenticationService`\n| Modal service | `TwoFactorAuthenticationModalService`\n| Setup service | `TwoFactorAuthenticationSetupService` (`views/partials/modals/two-factor/…`)\n| Route guards | `TwoFactorAuthenticationGuard`, `TwoFactorInfoLoadedGuard`\n| Setup banner | `TwoFactorSetupBannerInitializerService`, `constants/two-factor-banners.const.ts`\n| Widgets | `two-factor-widget`, `two-factor-setup-widget`\n| Banner component | `two-factor-authentication-required-banner`\n| Permission | `TWO_FACTOR_SETUP` (default for every authenticated supplier)\n|===\n\n== State shape\n\n[source,typescript]\n----\nexport const twoFAFeatureKey = '2fa';\n\nexport interface TwoFAState {\n    authyId?: string;                 // Authy registration identifier\n    authyRegVerified?: boolean;       // phone number has been verified\n    countryCode?: string;\n    phoneNumber?: string;\n    twoFactChangeConfirmed?: boolean;\n    initialized: boolean;             // the slice has been loaded at least once\n    loading?: boolean;\n    error?: boolean;\n    register2FAPhoneSend?: boolean;\n    smsSend?: boolean;\n}\n----\n\nA supplier counts as *configured* when `authyId` is present **and**\n`authyRegVerified` is true.\n\n== Enrolment steps\n\n[source,typescript]\n----\nexport enum TwoFactorAuthenticationSteps {\n    RegisterPhoneNumber = 'register',\n    SendSms             = 'sendSms',\n    VerifyCode          = 'test',\n}\n----\n\n== Actions\n\n[cols=\"2,3\"]\n|===\n| Action | Meaning\n\n| `fetch2FAInfo` | Load the current 2FA configuration.\n| `loaded2FAInfo` | Configuration received; sets `initialized: true`.\n| `register2FAPhoneNumber` | Register a phone number with Authy. Accepts `bypassHttpErrorsInterceptor`.\n| `register2FAPhoneNumberSuccess` | Registration accepted; resets `authyRegVerified` to `false`.\n| `register2FAPhoneNumberFailed` | Registration rejected.\n| `sendSms` | Request an SMS code.\n| `sentSmsSuccess` / `sendSmsError` | SMS dispatch outcome.\n| `verifySmsCode` | Verify the code during *enrolment*.\n| `phoneVerified` / `verifySmsCodeError` | Enrolment verification outcome.\n| `confirmSMSCode` | Verify the code during a *challenge* (accepts extra headers).\n| `smsConfirmed` | Challenge passed.\n|===\n\n[NOTE]\n====\n`verifySmsCode` and `confirmSMSCode` look similar but serve different purposes:\nthe first completes enrolment, the second satisfies a step-up challenge on an\nalready-enrolled account.\n====\n\n== Enforcement logic\n\n`TwoFactorAuthenticationService.checkTwoFactorAuthenticationState(force)` is the\nsingle decision point, called by `TwoFactorAuthenticationGuard`.\n\n[source,typescript]\n----\nasync checkTwoFactorAuthenticationState(force = false): Promise<boolean> {\n    const is2FaConfigured  = await this.is2FaConfigured().toPromise();\n    const is2FaSmsVerified = !force ? this.userService.isTFAConfirmed() : false;\n\n    switch (true) {\n        case !is2FaConfigured:\n            !this.checkInProgress && this.configure2FA().toPromise().then();  // <1>\n            return false;\n        case is2FaSmsVerified:\n            return true;                                                      // <2>\n        default:\n            return await this.open2FaSMSCodeVerificationModal(force).toPromise(); // <3>\n    }\n}\n----\n<1> Not enrolled → open the setup wizard and deny navigation. A `checkInProgress`\n    flag prevents a second wizard from stacking.\n<2> Already challenged this session and `force` is not set → allow.\n<3> Enrolled but not challenged (or `force` is set) → open the SMS code modal and\n    let the result decide.\n\n`force` comes from `route.data.force2FAConfirmation`. `ManageAccountModule`\nsets it to `true`, so entering account settings always re-challenges even if the\nsession was already verified.\n\n== Enrolment flow\n\n[listing]\n----\n/manage-account/security/two-factor\n  → TwoFactorInfoLoadedGuard dispatches fetch2FAInfo() and waits for loaded2FAInfo\n  → two-factor-setup-widget renders the current state\n\n  Step 1  register  → register2FAPhoneNumber({ countryCode, phoneNumber })\n                    → register2FAPhoneNumberSuccess (authyRegVerified = false)\n  Step 2  sendSms   → sendSms() → sentSmsSuccess\n  Step 3  test      → verifySmsCode({ verificationCode })\n                    → phoneVerified  (authyId + authyRegVerified now set)\n----\n\nPhone input uses `ngx-intl-tel-input` with `google-libphonenumber` validation, so\nthe country code and national number are validated client-side before the API is\ncalled.\n\n== Email confirmation link\n\nConfirmation emails link to `/login/confirm/email/:emailcode`, which the root\nrouter redirects to `manage-account/two-factor/confirm/:emailcode`. That route\nrenders `ManageAccountComponent`, which consumes the code and dispatches\n`confirmSMSCode`. Success dispatches `userConfirmed2Fa`; failure dispatches\n`userConfirmed2FaError`.\n\n== Setup banner\n\n`TwoFactorSetupBannerInitializerService` runs from\n`GlobalSubscriptionsService.init()`. If the supplier has not enrolled it shows a\npersistent banner (definitions in `constants/two-factor-banners.const.ts`) whose\nCTA opens the setup wizard. The banner is also available as a standalone\ncomponent, `two-factor-authentication-required-banner`.\n\n== Error handling\n\nBoth `register2FAPhoneNumber` and `sendSms` accept a\n`bypassHttpErrorsInterceptor` flag. When set, the effect adds the\n`bypassHttpErrorHandlerInterceptor` header so the generic error toast is\nsuppressed and the modal can render an inline, field-level message instead. See\nxref:security/interceptors.adoc[HTTP Interceptors].\n"},{"component":"supplier-client","module":"ROOT","path":"security/permissions.adoc","title":"Permissions System","description":"How the client derives every feature flag from the User object, and the exact condition behind each permission.","url":"https://docx.payability.com/supplier-client/security/permissions.html","navOrder":13,"source":"= Permissions System\n:description: How the client derives every feature flag from the User object, and the exact condition behind each permission.\n\n[IMPORTANT]\n====\nPermissions are *calculated on the client*, from the `User` object, every time\nthe user is loaded or their status changes. They are not returned by the API and\nnot stored server-side. If a supplier can or cannot see something, the answer is\nin `src/app/core/pyb/services/permissions.service.ts`.\n====\n\n== Pipeline\n\n[listing]\n----\nuserLoaded / userUpdated / userStatusUpdated\n  → userPermissionsRequested\n  → AuthEffects.loadedUserPermissions$\n        PermissionsService.getUserPermissions(user)\n            getDefaultPermissions()              always granted\n          + calculatePermissionsFromUserState()  derived from User.status\n          + getDevelopPermissions()              non-production only\n          → de-duplicated via Set\n  → userPermissionsLoaded({ permissions })\n  → permissionsReducer stores hierarchical Permission nodes\n  → GlobalSubscriptionsService.loadRolesWithPermissions()\n        flushPermissions() then addPermission() for every node,\n        prefixing children with the parent name\n  → NgxPermissionsService now answers *ngxPermissionsOnly and NgxPermissionsGuard\n----\n\n[source,typescript]\n----\ngetUserPermissions(user: User): PermissionsEnum[] {\n    const permissions = [...this.getDefaultPermissions(), ...this.calculatePermissionsFromUserState(user)];\n    const developPermissions = this.getDevelopPermissions();\n    if (!this.environmentService.production) {\n        console.group('Please be aware those permissions are available only on dev environments');\n        console.log(developPermissions.map(p => PermissionsEnum[p]));\n        console.groupEnd();\n        permissions.push(...developPermissions);\n    }\n    return Array.from(new Set(permissions));\n}\n----\n\n== Recalculation triggers\n\n[cols=\"2,3\"]\n|===\n| Action | Effect\n\n| `userLoaded` | Fired after `getUserByToken()` at login or session restore.\n| `userUpdated` | `AuthEffects.userUpdated$` → `userPermissionsRequested`.\n| `userStatusUpdated` | `AuthEffects.userStatusUpdated$` → `userPermissionsRequested`.\n| `userStatusUpdateRequested` | Re-fetches `getSupplierStatus(id)` → `userStatusUpdated` → recalculation.\n|===\n\nAny code that changes a supplier's eligibility — a completed withdrawal, a signed\ncontract, an activation — dispatches `userStatusUpdateRequested()` so permissions\ncatch up.\n\n== Default permissions\n\nGranted to every authenticated supplier, unconditionally.\n\n[cols=\"2,3\"]\n|===\n| Permission | Grants\n\n| `MY_MARKETPLACES` | The marketplace management page.\n| `DASHBOARD` | The dashboard.\n| `BETA` | Beta feature flag.\n| `MANAGE_TRANSFER_INFORMATION` | Transfer-information settings screen.\n| `LONG_TERM_CONTRACT` | Long Term Contract features.\n| `CHANGE_PASSWORD` | Password change in security settings.\n| `TWO_FACTOR_SETUP` | 2FA configuration.\n| `SHOPIFY_API_CONNECTION` | Shopify connector monitoring and reconnect prompts.\n| `AMAZON_API_CONNECTION` | Amazon connector monitoring and reconnect prompts.\n| `DARK_MODE` | Dark-mode toggle.\n|===\n\n[NOTE]\n====\n`BETA` is granted by `getDefaultPermissions()` but is *not* present in the\n`permissions.const.ts` tree, so it never reaches `NgxPermissionsService`. It is\nusable through the store selector but not through `*ngxPermissionsOnly`.\n====\n\n== Development-only permissions\n\n`getDevelopPermissions()` returns `[NEW_EGG_PROVIDER]`, added only when\n`environment.production` is false, and logged to the console as a reminder.\n\n[WARNING]\n====\nAnything gated on `NEW_EGG_PROVIDER` works in dev and staging but is invisible in\nproduction. Do not use it to gate a feature that is expected to ship.\n====\n\n== Advance Line permissions\n\n`calculateAdvanceLinePermissionsFromUser(user)`.\n\n[IMPORTANT]\n====\nThe whole block short-circuits when there is no Advance Line, or when\n`advanceLine.type === AdvanceLineModeEnum.Legacy`. Legacy suppliers get *no*\nAdvance Line permissions — their product surface is Instant Advance.\n====\n\nThree intermediate predicates:\n\n[source,typescript]\n----\nconst isAdvanceLineExpired  = new Date() > new Date(advanceLine.expirationDate);\nconst isAdvanceLineActive   = advanceLine.status   === AdvanceLineStatusEnum.Active;\nconst isAdvanceLineRejected = advanceLine.uwStatus === AdvanceLineUWStatusEnum.Rejected;\n----\n\n[cols=\"2,3\"]\n|===\n| Permission | Condition\n\n| `VIEW_ADVANCE_LINE_WIDGET`\n| `isAdvanceLineActive` **or** `isUserPendingAndExpedited(user)` **or** `isAdvanceLineExpired`\n\n| `ADVANCE_LINE_EXPIRED`\n| `isAdvanceLineExpired` **and** not active **and** not rejected\n\n| `ADVANCE_LINE_REJECTED`\n| `isAdvanceLineRejected` **or** (`isAdvanceLineExpired` **and** `isAdvanceLineActive`)\n\n| `ADVANCE_LINE_PENDING_ACTIVATION`\n| `isUserPending(supplierStatus)` **and** `advanceLine.status === Pending` **and**\n  `user.schema === OnboardingSchemaEnum.Expedited`\n\n| `WITHDRAW_ADVANCE_LINE`\n| `advanceLine.outstandingAmount > 0` **and** `supplierStatus !== Suspended`\n\n| `VIEW_ADVANCE_LINE_REPORT`\n| `advanceLine.currentAmount > 0`\n|===\n\n[NOTE]\n====\n`ADVANCE_LINE_PENDING_ACTIVATION` checks `OnboardingSchemaEnum.Expedited`\n(`exp_onb`) specifically. `AmazonExpedited` (`amzn_exp_onb`) does *not* satisfy\nit, because `isUserPendingAndExpedited()` also compares against `Expedited` only.\n====\n\n== Instant Advance permissions\n\n`calculateInstantAdvancePermissionsFromUser(user)`.\n\n[source,typescript]\n----\nconst hasValidActiveAdvance =\n    status.activeInstantAdvance &&\n    (!status.advanceLine || status.advanceLine?.type === AdvanceLineModeEnum.Legacy);\n\nconst hasValidPendingAdvance =\n    status.advanceLine?.type   === AdvanceLineModeEnum.Legacy &&\n    status.advanceLine?.status === AdvanceLineStatusEnum.Pending;\n----\n\n[cols=\"2,3\"]\n|===\n| Permission | Condition\n\n| `INSTANT_ADVANCE_REPORT` + `INSTANT_ADVANCE_REPORT_MVP`\n| `status.hasInstantAdvHistory` (both granted together)\n\n| `INSTANT_ADVANCE_REPORT_WIDGET`\n| `hasValidActiveAdvance` — an active Instant Advance *and* either no Advance\n  Line at all or a legacy one\n\n| `INSTANT_ADVANCE_PENDING`\n| `hasValidPendingAdvance` **and** `isUserPendingAndExpedited(user)`\n|===\n\nThis is the mutual exclusion between the two advance products: a supplier on a\nmodern Advance Line never sees the Instant Advance widget.\n\n== Remaining state-based permissions\n\n`calculatePermissionsFromUserState(user)`.\n\n[cols=\"2,3\"]\n|===\n| Permission | Condition\n\n| `TRANSFER_INFORMATION`\n| `!isUserPending(supplierStatus)` **or** `status.pmtCfgProvided`\n\n| `VENDOR_PAY`\n| `status.hasForwardPaymentEnabled`\n\n| `REFERRAL`\n| `!isUserDuringOnboarding(user)`\n\n| `TRANSFER_FUNDS`\n| `supplierStatus === Active` **or** `supplierStatus === Suspended`\n\n| `VIEW_NOTIFICATIONS`\n| `!isUserPendingAndExpedited(user)`\n\n| `REPORTS` + `DAILY_SUMMARY`\n| `status.hasPaymentHistory` **and** `!isUserDuringOnboarding(user)` (granted together)\n\n| `SELLER_CARD`\n| `isSellerCardAvailable(user)` — see below\n\n| `LCT_HISTORY`\n| `status.hasLTCHistory`\n\n| `INSTANT_TRANSFER_METHOD`\n| `user.ppmAvailable`\n|===\n\n[NOTE]\n====\nA `Suspended` supplier keeps `TRANSFER_FUNDS`. Suspension restricts new advances\nand card usage, not access to money already owed.\n====\n\n== Seller Card eligibility\n\n[source,typescript]\n----\nprivate isSellerCardAvailable(user: User): boolean {\n    const hasPaymentHistory = user?.status?.hasPaymentHistory;\n    const pcardAvailable    = user?.pcardAvailable;\n    const isFromUS          = this.isFromUS(user);\n    return hasPaymentHistory && (pcardAvailable || isFromUS) && !this.isUserDuringOnboarding(user);\n}\n\nprivate isFromUS(user: User): boolean {\n    const country            = user?._businessInformation?.address?.country;\n    const residentialCountry = user?._personalInformation?.residentialAddress?.country;\n    return [country, residentialCountry].every(code => code === CountryCode.US);\n}\n----\n\n[WARNING]\n====\n`isFromUS()` requires **both** the business address and the residential address\nto be `US`. A US-incorporated business with an overseas owner fails the test and\nmust be enabled explicitly through `pcardAvailable`.\n====\n\n== Shared predicates\n\n[source,typescript]\n----\nprivate isUserPending = (s: SupplierStatusType): boolean =>\n    s === SupplierStatusType.PendingActivation || s === SupplierStatusType.Pending;\n\nprivate isUserPendingAndExpedited = (user: User): boolean =>\n    this.isUserPending(user.status.supplierStatus) && user.schema === OnboardingSchemaEnum.Expedited;\n\nprivate isUserDuringOnboarding = (user: User): boolean =>\n    user.dashboardStatus?.status === SupplierDashboardStatusType.Onboarding;\n----\n\n== The permission enum\n\n`src/app/core/pyb/enums/permissions.enum.ts` — a *numeric* enum, so values are\npositional indices. Reordering members changes every persisted numeric value;\nalways append.\n\n[source,typescript]\n----\nexport enum PermissionsEnum {\n    ADVANCE_LINE,\n    ADVANCE_LINE_EXPIRED,\n    ADVANCE_LINE_PENDING_ACTIVATION,\n    AMAZON_API_CONNECTION,\n    BETA,\n    CHANGE_PASSWORD,\n    DAILY_SUMMARY,\n    DASHBOARD,\n    GOD_MODE,\n    INSTANT_ADVANCE_REPORT,\n    INSTANT_ADVANCE_REPORT_WIDGET,\n    INSTANT_ADVANCE_REPORT_MVP,\n    VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT,\n    INSTANT_TRANSFER_METHOD,\n    LCT_HISTORY,\n    LONG_TERM_CONTRACT,\n    MANAGE_TRANSFER_INFORMATION,\n    PROD_MODE,\n    REFERRAL,\n    REPORTS,\n    SELLER_CARD,\n    SHOPIFY_API_CONNECTION,\n    TRANSFER_FUNDS,\n    TRANSFER_INFORMATION,\n    TWO_FACTOR_SETUP,\n    VIEW_ADVANCE_LINE_REPORT,\n    VIEW_ADVANCE_LINE_WIDGET,\n    VIEW_NOTIFICATIONS,\n    WITHDRAW_ADVANCE_LINE,\n    INSTANT_ADVANCE_PENDING,\n    DARK_MODE,\n    NEW_EGG_PROVIDER,\n    MY_MARKETPLACES,\n    ADVANCE_LINE_REJECTED,\n    VENDOR_PAY,\n}\n----\n\n`GOD_MODE` and `PROD_MODE` are declared and present in the permission tree but\nare never granted by `PermissionsService`.\n\n== The permission tree\n\n`src/app/core/pyb/services/permissions.const.ts` turns the flat enum into a\nhierarchy. Children are addressed with a dotted, parent-prefixed name.\n\n[source,typescript]\n----\nconst permissions = [\n    { id: PermissionsEnum.DASHBOARD },\n    { id: PermissionsEnum.SELLER_CARD },\n    {\n        id: PermissionsEnum.REPORTS,\n        children: [\n            { id: PermissionsEnum.INSTANT_ADVANCE_REPORT },\n            { id: PermissionsEnum.INSTANT_ADVANCE_REPORT_MVP },\n            { id: PermissionsEnum.VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT },\n            { id: PermissionsEnum.DAILY_SUMMARY },\n        ],\n    },\n    { id: PermissionsEnum.TRANSFER_FUNDS },\n    { id: PermissionsEnum.GOD_MODE },\n    {\n        id: PermissionsEnum.ADVANCE_LINE,\n        children: [\n            { id: PermissionsEnum.ADVANCE_LINE_EXPIRED },\n            { id: PermissionsEnum.ADVANCE_LINE_REJECTED },\n            { id: PermissionsEnum.VIEW_ADVANCE_LINE_WIDGET },\n            { id: PermissionsEnum.VIEW_ADVANCE_LINE_REPORT },\n            { id: PermissionsEnum.WITHDRAW_ADVANCE_LINE },\n            { id: PermissionsEnum.ADVANCE_LINE_PENDING_ACTIVATION },\n        ],\n    },\n    // …flat entries for the remaining permissions\n];\n----\n\nThe default export is an IIFE that walks the tree and builds `Permission` nodes\nwith `name`, `level`, `parentId` and `children`.\n\n=== Resulting names\n\n[cols=\"2,2\"]\n|===\n| Enum member | `ngx-permissions` name\n\n| `DASHBOARD` | `DASHBOARD`\n| `REPORTS` | `REPORTS`\n| `DAILY_SUMMARY` | `REPORTS.DAILY_SUMMARY`\n| `INSTANT_ADVANCE_REPORT` | `REPORTS.INSTANT_ADVANCE_REPORT`\n| `INSTANT_ADVANCE_REPORT_MVP` | `REPORTS.INSTANT_ADVANCE_REPORT_MVP`\n| `VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT` | `REPORTS.VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT`\n| `ADVANCE_LINE` | `ADVANCE_LINE`\n| `VIEW_ADVANCE_LINE_WIDGET` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_WIDGET`\n| `WITHDRAW_ADVANCE_LINE` | `ADVANCE_LINE.WITHDRAW_ADVANCE_LINE`\n| `ADVANCE_LINE_PENDING_ACTIVATION` | `ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION`\n| `SELLER_CARD` | `SELLER_CARD`\n|===\n\n[WARNING]\n====\nGuards and template directives must use the *full dotted name*. This is why\n`DocusignVerificationService` calls\n`removePermission('ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION')` and the\ndaily-summary route asks for `REPORTS.DAILY_SUMMARY`, not the bare names.\n====\n\n[NOTE]\n====\n`ADVANCE_LINE` and `REPORTS` are parents that `PermissionsService` never grants\ndirectly. A child such as `ADVANCE_LINE.WITHDRAW_ADVANCE_LINE` is registered\nwhether or not its parent was granted, because\n`GlobalSubscriptionsService.loadRolesWithPermissions()` walks whatever nodes the\nreducer produced.\n====\n\n== Consuming permissions\n\n=== In templates\n\n[source,html]\n----\n<div *ngxPermissionsOnly=\"['SELLER_CARD']\">\n    <pyb-seller-cards-widget></pyb-seller-cards-widget>\n</div>\n\n<div *ngxPermissionsExcept=\"['ADVANCE_LINE.ADVANCE_LINE_EXPIRED']\">\n    <pyb-advance-line-widget></pyb-advance-line-widget>\n</div>\n----\n\n=== In routes\n\n[source,typescript]\n----\n{\n    path: 'daily-summary-report',\n    canActivate: [NgxPermissionsGuard],\n    data: { permissions: { only: ['REPORTS.DAILY_SUMMARY'] } },\n}\n----\n\n=== In the navigation menu\n\n`MenuConfig` items carry a `permission` string; items the user lacks are hidden.\n\n=== From code\n\n[source,typescript]\n----\nthis.store.select(currentUserPermissions);                       // Permission[] from the store\nthis.ngxPermissionsService.hasPermission('SELLER_CARD');         // Promise<boolean>\nthis.ngxPermissionsService.removePermission('INSTANT_ADVANCE_PENDING');\n----\n\nBusiness flows revoke permissions optimistically after a successful action so the\nUI updates before `userStatusUpdateRequested()` round-trips.\n\n== Adding a permission\n\n. Append the member to `PermissionsEnum` — never insert in the middle.\n. Add it to the tree in `permissions.const.ts`, nested if it belongs under a parent.\n. Grant it from `getDefaultPermissions()` or `calculatePermissionsFromUserState()`.\n. Use the full dotted name in guards, templates and `MenuConfig`.\n. Extend `permissions.service.spec.ts` with the new condition.\n"},{"component":"supplier-client","module":"ROOT","path":"state/overview.adoc","title":"NgRX Overview","description":"Store configuration, the complete slice map, and the conventions every slice follows.","url":"https://docx.payability.com/supplier-client/state/overview.html","navOrder":14,"source":"= NgRX Overview\n:description: Store configuration, the complete slice map, and the conventions every slice follows.\n\nThe application uses NgRX {ngrx-version}: `@ngrx/store`, `@ngrx/effects`,\n`@ngrx/entity` and `@ngrx/router-store`.\n\n== Root configuration\n\n`AppModule` registers a deliberately *empty* root reducer map — only the router.\nEverything else attaches through `StoreModule.forFeature()`.\n\n[source,typescript]\n----\n// src/app/core/reducers/index.ts\nexport type AppState = Record<string, any>;\n\nexport const reducers: ActionReducerMap<AppState> = { router: routerReducer };\n\nexport const metaReducers: MetaReducer<AppState>[] =\n    !environment.production ? [storeFreeze] : [];\n----\n\n[source,typescript]\n----\n// src/app/app.module.ts\nStoreModule.forRoot(reducers, {\n    metaReducers,\n    runtimeChecks: {\n        strictActionImmutability: false,   // <1>\n        strictStateImmutability: false,\n    },\n}),\nEffectsModule.forRoot([]),\nStoreRouterConnectingModule.forRoot({ stateKey: 'router' }),\nStoreDevtoolsModule.instrument({ logOnly: true }),\n----\n<1> NgRX's own runtime checks are disabled because `storeFreeze` already enforces\n    immutability outside production, and the built-in checks produced false\n    positives against the class-based models used here.\n\n[NOTE]\n====\n`AppState` is `Record<string, any>` rather than a hand-maintained interface.\nType safety comes from the per-slice `State` interfaces and their selectors, not\nfrom the root type.\n====\n\n== Complete slice map\n\n[cols=\"2,2,3\"]\n|===\n| Key | Registered by | Contents\n\n| `router` | `AppModule` (root) | Serialized router state\n| `auth` | `AuthModule` | Session, user, dashboard status\n| `permissions` | `ThemeModule` | Calculated `Permission` tree\n| `balance` | `BalanceFeatureModule` | Available balance\n| `paymentMethods` | `PaymentMethodsFeatureModule` | Configured payout methods, including p-cards\n| `personalInformation` | `PersonalInformationFeatureModule` | Contact and residential details\n| `businessInformation` | `BusinessInformationFeatureModule` | Legal entity details\n| `plaid` | `PlaidFeatureModule` | Plaid Link session state\n| `2fa` | `TwoFactorAuthenticationFeatureModule` | Authy enrolment and challenge state\n| `advanceLine` | `AdvanceLineFeatureModule` | Advance Line summary\n| `advanceLineOffers` | `AdvanceLineFeatureModule` | Available Advance Line offers\n| `instantAdvance` | `InstantAdvanceFeatureModule` | Instant Advance product data\n| `instantAdvanceRepayment` | `InstantAdvanceRepaymentModule` | Repayment schedule and history\n| `fees` | `FeesStoreModule` | Fee schedule\n| `dashboardNotifications` | `DashboardNotificationsFeatureModule` | Server-driven notification set\n| `blog` | `BlogStoreModule` | CMS blog posts\n| `productSummary` | `ProductSummaryModule` | Product offerings summary\n| `airwallexCards` | `AirwallexCardsFeatureModule` | Airwallex card collection\n| `cardOrder` | `CardOrderFeatureModule` | Card ordering wizard state\n| `supplierMarketplaces` | `SupplierMarketplacesFeatureModule` | Entity — the supplier's connected channels\n| `marketplaces` | `MarketplacesFeatureModule` | Entity — the catalog of supported marketplaces\n| `countries` | `CountriesFeatureModule` | Entity — country reference data\n| `onboarding` | `OnboardingFeatureModule` | Entity — onboarding step progress\n| `reports.dailySummaryReports` | `DailySummaryReportModule` | Entity — paginated daily summaries\n|===\n\n`FeaturesModule` (`core/pyb/features/features.module.ts`) imports and re-exports\nevery slice module, and is itself imported once by `AppModule`. Adding a slice\nmeans adding it in two places inside that file — `imports` and `exports`.\n\n[NOTE]\n====\n`reports.dailySummaryReports` contains a dot, which reads like a nested key but\nis a single flat feature key. Select it with\n`createFeatureSelector('reports.dailySummaryReports')`, not by walking into a\n`reports` object.\n====\n\n== Immutability\n\n`storeFreeze` is applied as a meta-reducer outside production. It deep-freezes\nstate and actions, so any accidental mutation throws immediately in development\nand CI.\n\n[WARNING]\n====\n`storeFreeze` is *not* applied in production. A mutation that slips past review\nfails loudly in dev and silently corrupts state in prod. Always return new\nobjects from reducers.\n====\n\n== Slice anatomy\n\nEvery feature folder follows the same shape:\n\n[source]\n----\nfeatures/<name>/\n├── <name>.actions.ts     createAction definitions\n├── <name>.reducer.ts     featureKey, State interface, initialState, createReducer\n├── <name>.selectors.ts   createFeatureSelector + createSelector\n├── <name>.effects.ts     API calls and other side effects\n├── <name>.module.ts      StoreModule.forFeature + EffectsModule.forFeature\n├── <name>.enums.ts       action-type strings and slice-local enums (optional)\n├── <name>.interfaces.ts  payload types (optional)\n└── index.ts              barrel\n----\n\n=== Actions\n\n[source,typescript]\n----\nexport const loadBalance        = createAction('[Balance] Load Balance');\nexport const loadBalanceSuccess = createAction('[Balance] Load Balance Success', props<{ balance: BalanceDto }>());\nexport const loadBalanceError   = createAction('[Balance] Load Balance Error',   props<{ error: HttpErrorResponse }>());\n----\n\nNames follow `[Feature] Description`. Larger slices hoist the strings into an\nenum (`DashboardNotificationsActionTypes`, `TwoFactorAuthenticationActionTypes`,\n`AuthActionTypes`) so effects and reducers cannot drift apart.\n\n=== Reducers\n\n[source,typescript]\n----\nexport const balanceFeatureKey = 'balance';\n\nexport interface State { balance: number; loading: boolean; error: boolean; }\n\nexport const initialState: State = { balance: null, loading: false, error: false };\n\nconst balanceReducer = createReducer(\n    initialState,\n    on(loadBalance,        state => ({ ...state, loading: true,  error: false })),\n    on(loadBalanceSuccess, (state, { balance }) => ({ ...state, balance, loading: false })),\n    on(loadBalanceError,   state => ({ ...state, loading: false, error: true })),\n);\n\nexport function reducer(state: State | undefined, action: Action) {\n    return balanceReducer(state, action);\n}\n----\n\nThe exported `reducer` wrapper exists for AOT compatibility on Angular\n{angular-version} — `createReducer` results cannot be referenced directly in a\ndecorator metadata expression.\n\nMost slices carry `loading` / `loaded` / `error` flags. Guards depend on them:\n`SellerCardListGuard` waits for `loaded && !loading` on two slices before\ndeciding where to route.\n\n=== Selectors\n\n[source,typescript]\n----\nexport const selectBalanceState = createFeatureSelector<State>(balanceFeatureKey);\nexport const selectBalance      = createSelector(selectBalanceState, s => s.balance);\nexport const selectIsLoading    = createSelector(selectBalanceState, s => s.loading);\n----\n\nSelectors are the only public read surface. Components never index into state.\n\n=== Effects\n\n[source,typescript]\n----\nloadBalance$ = createEffect(() =>\n    this.actions$.pipe(\n        ofType(loadBalance),\n        switchMap(() => this.balanceService.get().pipe(\n            map(balance => loadBalanceSuccess({ balance })),\n            catchError(error => of(loadBalanceError({ error }))),\n        )),\n    ),\n);\n----\n\nOperator choice is meaningful:\n\n[cols=\"1,3\"]\n|===\n| Operator | Used when\n\n| `switchMap` | Only the latest request matters — reloads, refreshes.\n| `concatMap` | Order matters and nothing may be dropped — sequenced dispatches.\n| `mergeMap` | Requests are independent and can overlap.\n| `exhaustMap` | Ignore new triggers while one is in flight — submit buttons, `DocuSignGuard`.\n|===\n\nEffects that only produce side effects declare `{ dispatch: false }`, as\n`AuthEffects.logout$` does.\n\n== Router state\n\n`StoreRouterConnectingModule.forRoot({ stateKey: 'router' })` mirrors router\nstate into the store, so effects can react to navigation and time-travel\ndebugging replays routes.\n\n== DevTools\n\n`StoreDevtoolsModule.instrument({ logOnly: true })` — the Redux DevTools\nextension can inspect and replay, but dispatching from the extension is\ndisabled.\n\n== Related pages\n\n* xref:state/auth-slice.adoc[Auth Slice]\n* xref:state/feature-slices.adoc[Feature Slices]\n* xref:state/entity-slices.adoc[Entity Slices]\n* xref:state/persistence.adoc[State Persistence]\n"},{"component":"supplier-client","module":"ROOT","path":"state/auth-slice.adoc","title":"Auth Slice","description":"Shape, actions, reducers, effects and selectors of the auth feature slice.","url":"https://docx.payability.com/supplier-client/state/auth-slice.html","navOrder":15,"source":"= Auth Slice\n:description: Shape, actions, reducers, effects and selectors of the auth feature slice.\n\nRegistered by `AuthModule` under the key `auth`, with the persistent\nmeta-reducer attached through `AUTH_CONFIG_TOKEN`:\n\n[source,typescript]\n----\nStoreModule.forFeature('auth', authReducer, AUTH_CONFIG_TOKEN),\nEffectsModule.forFeature([AuthEffects, UserStatusEffects]),\n----\n\n== Shape\n\nThe slice is a `combineReducers` of three sub-states.\n\n[source,typescript]\n----\nexport interface AuthState {\n    auth: LoginState;\n    recoverPasswordRequest: RecoverPasswordRequestState;\n    newPasswordRequest: NewPasswordRequestState;\n}\n\nexport interface LoginState {\n    loggedIn: boolean;\n    authToken: string;\n    user: Partial<User>;\n    isUserLoaded: boolean;\n    statusLoading: boolean;\n    dashboardStatusV2: SupplierDashboardStatusV2Dto;\n}\n\nexport interface RecoverPasswordRequestState { isPending: boolean; requestSent: boolean; error: string; }\nexport interface NewPasswordRequestState     { isPending: boolean; requestSend: boolean; error: string; }\n----\n\n[NOTE]\n====\nThere are two dashboard-status representations. `user.dashboardStatus`\n(`SupplierDashboardStatusType`: `onboarding` \\| `welcome` \\|\n`inactive_dashboard` \\| `active`) drives the permission calculation, while\n`dashboardStatusV2` (`SupplierDashboardStatusTypeV2` from the API package) is the\nnewer server-side status used by `GlobalSubscriptionsService`. Read the one the\nsurrounding code reads.\n====\n\n== Action types\n\n`src/app/core/auth/_actions/actions.enums.ts`\n\n[cols=\"2,3\"]\n|===\n| Member | String\n\n| `Login` | `[Login] Action`\n| `Logout` | `[Logout] Action`\n| `Register` | `[Register] Action`\n| `UserRequested` | `[Request User] Action`\n| `UserLoaded` | `[Load User] Auth API`\n| `UserUpdated` | `[Update User] Update User`\n| `GetUserByToken` | `[User] Get User By Token`\n| `UserConfirmed2FA` | `[User Confirmed 2FA] Confirmed 2FA`\n| `UserConfirmed2FAError` | `[User Confirmed 2FA] Confirmed 2FA Error`\n| `UserPermissionsRequested` | `[User Permissions] User permissions requested`\n| `UserPermissionsLoaded` | `[User Permissions] User permissions loaded`\n| `UserPermissionAdded` | `[User Permissions] User permission added`\n| `UserPermissionRemoved` | `[User Permissions] User permission removed`\n| `UserStatusUpdateRequested` | `[Request User Status] User status update requested`\n| `UserStatusUpdated` | `[Update User Status] User status updated`\n| `UserDashboardStatusRequested` | `[User Dashboard Status] User dashboard status requested`\n| `UserDashboardStatusLoaded` | `[User Dashboard Status] User dashboard status loaded`\n| `RecoverPasswordRequest` / `…Error` / `…Success` / `…Reset` | `[Recover Password] …`\n| `NewPasswordRequest` / `…Error` / `…Success` / `…Reset` | `[New Password] …`\n|===\n\n`noAction` (`'NO_ACTION'`) is a no-op used by `init$` when there is no stored token.\n\n== Reducer behaviour\n\n[cols=\"2,3\"]\n|===\n| Action | State change\n\n| `login` | `loggedIn: true`, store `authToken`, `isUserLoaded: false`, `statusLoading: true`\n| `logout` | `loggedIn: false` (the effect then clears storage and reloads the page)\n| `register` | Store `authToken`, `loggedIn: true`, clear `user`\n| `userLoaded` | Set `user`, `isUserLoaded: true`, `statusLoading: false`\n| `userUpdated` | Replace `user`, `isUserLoaded: true`\n| `userStatusUpdateRequested` | `statusLoading: true`\n| `userStatusUpdated` | Merge `status` into `user`, `statusLoading: false`\n| `userDashboardStatusLoaded` | Set `dashboardStatusV2`\n| `userConfirmed2Fa` | Replace `user`\n| `userPermissionsLoaded` | Set `user.permissions`\n| `userPermissionAdded` | `union([permission], state.user.permissions)`\n|===\n\n[WARNING]\n====\n`userPermissionRemoved` is defined as an action but has *no* `on()` handler in\n`userReducer`. Permission revocation at runtime goes through\n`NgxPermissionsService.removePermission()` instead — for example in\n`DocusignVerificationService`. The store copy stays stale until the next\nrecalculation.\n====\n\n== Effects\n\n`src/app/core/auth/_effects/auth.effects.ts`\n\n[cols=\"2,2,3\"]\n|===\n| Effect | Trigger | Behaviour\n\n| `init$` | app start (`defer`) | Reads `localStorage[authTokenKey]`; dispatches `login` or `noAction`.\n| `login$` | `login` | Writes the token to `localStorage`; dispatches `userRequested`.\n| `register$` | `register` | Same as `login$`.\n| `logout$` | `logout` | `stateManager.clear()`, removes the token, reloads the page after 1 s. `{ dispatch: false }`.\n| `loadUser$` | `userRequested` | Skips when the user is already loaded; calls `getUserByToken()`; `eventLogger.identify(user)`; dispatches `userLoaded`, `userPermissionsRequested`, `userDashboardStatusRequested`, `loadBalance`. Falls back to `logout()` when no user comes back.\n| `getUserByToken$` | `getUserByToken` | Re-fetches the user; dispatches `userUpdated`.\n| `updateUserStatus$` | `userStatusUpdateRequested` | `getSupplierStatus(id)` → `userStatusUpdated({ status: Status.fromDto(status) })`.\n| `userUpdated$` | `userUpdated` | → `userPermissionsRequested`.\n| `userStatusUpdated$` | `userStatusUpdated` | → `userPermissionsRequested`.\n| `userDashboardStatusRequested$` | `userDashboardStatusRequested` | `getSupplierDasboardStatusV2(user.id)` → `userDashboardStatusLoaded`.\n| `loadedUserPermissions$` | `userPermissionsRequested` | `PermissionsService.getUserPermissions(user)` → `userPermissionsLoaded`.\n| `recoverUserPasswordEmail$` | `recoverPasswordEmailRequest` | `requestPassword()` → success or error action.\n| `newUserPasswordRequest$` | `newPasswordRequest` | `confirmPasswordReset()` then `executePasswordChange()`.\n|===\n\n`UserStatusEffects` (`_effects/user-status.effects.ts`) is registered alongside\nand handles status-driven follow-up work.\n\n[NOTE]\n====\n`loadUser$` uses `filter((_, isLoaded) => !isLoaded)`. The second argument of\nRxJS `filter` is the emission *index*, not the `withLatestFrom` value — for the\nfirst emission it is `0`, which is falsy, so the filter passes. The guard\ntherefore only really protects against repeat emissions.\n====\n\n== Selectors\n\n`src/app/core/auth/_selectors/auth.selectors.ts`\n\n=== Session\n\n[cols=\"2,3\"]\n|===\n| Selector | Returns\n\n| `selectAuthState` | The whole `auth` feature state\n| `selectLoginState` | `state.auth` (the `LoginState`)\n| `isLoggedIn` / `isLoggedOut` | Session boolean\n| `selectCurrentAuthToken` | Raw token string\n| `selectIsUserLoaded` | Whether the user object has arrived\n|===\n\n=== User\n\n[cols=\"2,3\"]\n|===\n| Selector | Returns\n\n| `selectCurrentUser` | The `User`\n| `selectCurrentUserId` | `user.id` (the supplier key)\n| `selectCurrentUserStatus` | `user.status`\n| `selectSupplierStatus` | `user.status.supplierStatus`\n| `selectSupplierOnboardSchema` | `user.schema`\n| `selectOnboardingTemplateCode` | `user.onboardingTemplateCode`\n| `selectDashboardStatusV2` | `dashboardStatusV2`\n|===\n\n=== Derived state\n\n[cols=\"2,3\"]\n|===\n| Selector | Condition\n\n| `selectIsSupplierActive` | `supplierStatus === Active`\n| `selectIsSupplierPending` | `supplierStatus === Pending`\n| `selectIsSupplierPendingActivation` | `supplierStatus === PendingActivation`\n| `selectUserCancellationStatus` | `user.cancelationStatus`\n| `selectIsSupplierCancelled` | `cancelationStatus === Canceled` **and** `supplierStatus === Suspended`\n| `selectHasInstantAdvHistory` | `status.hasInstantAdvHistory`\n| `selectActiveInstantAdv` | `status.activeInstantAdvance`\n| `selectLtcActive` | `status.hasActiveLTC`\n| `selectRenewLTCSigned` | `status.renewLTCSigned`\n| `selectAmazonApiSwitching` | `user.amazonApiSwitching`\n| `selectIsLegalSigned` | `status.legalSigned`\n| `currentUserPermissions` | The hierarchical `Permission[]`\n|===\n\n== The `permissions` slice\n\nRegistered separately by `ThemeModule` under the key `permissions`, using\n`@ngrx/entity`:\n\n[source,typescript]\n----\nexport interface State extends EntityState<Permission> {\n    isAllPermissionsLoaded: boolean;\n}\n\nexport const adapter = createEntityAdapter<Permission>({ selectId: entity => entity.id });\n----\n\nHandled actions: `allPermissionsRequested`, `allPermissionsLoaded` (`setAll`),\n`permissionAdded` (`addOne`), `permissionRemoved` (`removeOne`).\n\n== Legacy sub-slices\n\n`user.reducers.ts` / `role.reducers.ts` (with their actions, effects, selectors\nand `users.datasource.ts` / `roles.datasource.ts`) are inherited from the admin\ntemplate the project started from. `UserActionTypes` and `RoleActionTypes` exist\nbut no supplier-facing flow dispatches them.\n"},{"component":"supplier-client","module":"ROOT","path":"state/feature-slices.adoc","title":"Feature Slices","description":"Every NgRX feature slice — key, purpose, actions and consumers.","url":"https://docx.payability.com/supplier-client/state/feature-slices.html","navOrder":16,"source":"= Feature Slices\n:description: Every NgRX feature slice — key, purpose, actions and consumers.\n\nAll feature slices live under `src/app/core/pyb/features/` and are wired up by\n`FeaturesModule`. Entity-backed collections are documented separately in\nxref:state/entity-slices.adoc[Entity Slices].\n\n== `balance`\n\n[cols=\"1,3\"]\n|===\n| Key | `balance`\n| Module | `BalanceFeatureModule`\n| Purpose | The supplier's currently available balance.\n| Actions | `loadBalance`, `loadBalanceSuccess`, `loadBalanceFailed`\n|===\n\n`loadBalance()` is dispatched from `AuthEffects.loadUser$` at session start and\nagain after any operation that moves money — advance withdrawals, instant\ntransfers, card funding.\n\n== `paymentMethods`\n\n[cols=\"1,3\"]\n|===\n| Key | `paymentMethods`\n| Module | `PaymentMethodsFeatureModule`\n| Purpose | Configured payout methods, including prepaid cards, plus the Seller Card request/activate lifecycle.\n|===\n\nActions:\n\n[cols=\"2,3\"]\n|===\n| Group | Actions\n\n| Load | `loadPaymentMethods`, `loadPaymentMethodsSuccess`, `loadPaymentMethodsFailed`\n| Trial days | `loadPaymentTrialDays`, `loadPaymentTrialDaysSuccess`, `loadPaymentTrialDaysFailed`\n| Mutate | `savePaymentMethod`, `savePaymentMethodSuccess`, `savePaymentMethodFailed`, `updatePaymentMethod`, `replacePaymentMethod`, `removePaymentMethod`, `makePaymentMethodAsDefault`\n| UI | `openAddPaymentMethodModal`\n| Seller Card | `sellerCardRequest`, `sellerCardSuccessResponse`, `sellerCardErrorResponse`, `sellerCardActivate`, `sellerCardActivateSuccess`, `sellerCardActivateFailed`\n|===\n\nKey selectors: `selectIsPaymentMethodsLoaded`, `selectPaymentsMethodLoading`,\n`selectPcardsPaymentMethods` — all three are read by `SellerCardListGuard`.\n\n[NOTE]\n====\nThe Marqeta Seller Card lives in this slice because it is modelled as a *payment\nmethod*. Airwallex cards are a separate slice (`airwallexCards`) because they\ncame from a different provider integration. See\nxref:business/seller-card.adoc[Seller Card].\n====\n\n== `personalInformation`\n\n[cols=\"1,3\"]\n|===\n| Key | `personalInformation`\n| Module | `PersonalInformationFeatureModule`\n| Purpose | Name, date of birth, phone, email, tax id, residential address.\n| Actions | `updatePersonalInformation`, `loadedPersonalInformation`, `updatedPersonalInformation`\n|===\n\n== `businessInformation`\n\n[cols=\"1,3\"]\n|===\n| Key | `businessInformation`\n| Module | `BusinessInformationFeatureModule`\n| Purpose | Legal name, business type, incorporation state, owner SSN, EIN, established date, business address.\n| Actions | `updateBusinessInformation`, `loadedBusinessInformation`, `updatedBusinessInformation`\n|===\n\nBoth information slices feed the Seller Card US-residency check — see\nxref:security/permissions.adoc[Permissions System].\n\n== `plaid`\n\n[cols=\"1,3\"]\n|===\n| Key | `plaid`\n| Module | `PlaidFeatureModule`\n| Purpose | Plaid Link session and token exchange.\n| Actions | `savePlaidToken`, `savePlaidTokenSuccess`, `savePlaidTokenFailed`, `savePlaidTokenRefresh`\n|===\n\n`savePlaidTokenRefresh` handles the re-authentication path when an existing item\nloses its connection. See xref:integrations/plaid.adoc[Plaid].\n\n== `2fa`\n\n[cols=\"1,3\"]\n|===\n| Key | `2fa`\n| Module | `TwoFactorAuthenticationFeatureModule`\n| Purpose | Authy enrolment and SMS challenge state.\n|===\n\nFully documented in xref:security/two-factor.adoc[Two-Factor Authentication].\n\n== `advanceLine` and `advanceLineOffers`\n\n`AdvanceLineFeatureModule` registers two slices.\n\n[cols=\"1,3\"]\n|===\n| Key | `advanceLine` (from `summary/summary.reducer.ts`)\n| Purpose | Advance Line summary — current amount, outstanding amount, expiration, status.\n| Actions | `loadSummary`, `loadSummarySuccess`, `loadSummaryFailure`, `advanceLineWithdrawSuccess`\n|===\n\n[cols=\"1,3\"]\n|===\n| Key | `advanceLineOffers` (from `offers/offers.reducer.ts`)\n| Purpose | Available draw offers and their terms.\n| Actions | `loadOffers`, `loadOffersSuccess`, `loadOffersFailure`, `clearOffers`, `withdrawOffer`\n|===\n\n`advanceLineWithdrawSuccess` is dispatched by `DocusignVerificationService` after\na confirmed withdrawal, alongside `loadBalance`, `loadSummary`,\n`fetchSupplierMarketplaces` and `userStatusUpdateRequested`. See\nxref:business/advance-line.adoc[Advance Line].\n\n== `instantAdvance`\n\n[cols=\"1,3\"]\n|===\n| Key | `instantAdvance`\n| Module | `InstantAdvanceFeatureModule`\n| Purpose | Instant Advance product data.\n| Actions | `loadInstantAdvance`, `loadHttpInstantAdvance`, `loadCacheInstantAdvance`, `resetCacheInstantAdvance`, `loadInstantAdvanceSuccess`, `loadInstantAdvanceFailure`\n|===\n\nThe slice implements a small cache: `loadInstantAdvance` decides between\n`loadCacheInstantAdvance` (serve what is already in the store) and\n`loadHttpInstantAdvance` (fetch). `resetCacheInstantAdvance` invalidates it after\na mutation.\n\n== `instantAdvanceRepayment`\n\n[cols=\"1,3\"]\n|===\n| Key | `instantAdvanceRepayment`\n| Module | `InstantAdvanceRepaymentModule`\n| Purpose | Repayment schedule and history for the active Instant Advance.\n| Actions | `loadInstantAdvanceRepayments`, `loadInstantAdvanceRepaymentsSuccess`, `loadInstantAdvanceRepaymentsError`\n|===\n\n== `fees`\n\n[cols=\"1,3\"]\n|===\n| Key | `fees`\n| Module | `FeesStoreModule`\n| Purpose | The fee schedule used by fee displays and the fee report.\n| Actions | `fetchFees`, `fetchFeesSuccess`, `fetchFeesError`\n|===\n\n`fetchFees()` is dispatched once from `GlobalSubscriptionsService.init()`.\n`FeesScheduleHelperService` derives display values from the slice.\n\n== `dashboardNotifications`\n\n[cols=\"1,3\"]\n|===\n| Key | `dashboardNotifications`\n| Module | `DashboardNotificationsFeatureModule`\n| Purpose | Server-driven notifications rendered as CTA cards or auto-opened modals.\n|===\n\nActions:\n\n[cols=\"2,3\"]\n|===\n| Group | Actions\n\n| Load | `fetchDashboardNotifications`, `loadedDashboardNotifications`, `updatedDashboardNotifications`, `clearDashboardNotifications`\n| Visibility | `visitedDashboardNotifications`, `hideDashboardNotifications`, `showDashboardNotifications`\n| Decline | `declineDashboardNotifications`, `declineDashboardNotificationsSuccess`, `declineDashboardNotificationsError`\n| Accept | `acceptDashboardNotifications`, `acceptDashboardNotificationsSuccess`, `acceptDashboardNotificationsError`\n| Other | `enableOpenInvoices`, `deleteNotification`\n|===\n\nAccept and decline payloads accept a `silent` flag that suppresses the\nsuccess modal — used when several notifications are resolved by one dialog.\nSee xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n\n== `blog`\n\n[cols=\"1,3\"]\n|===\n| Key | `blog`\n| Module | `BlogStoreModule`\n| Purpose | CMS blog posts for the Blog page.\n| Actions | `fetchPosts`, `fetchPostsSuccess`, `fetchPostsError`, `openPost`, `trackBlogNavigate`\n|===\n\n`trackBlogNavigate` exists purely to emit\n`BlogEvents.CustomerBlogPostNavigate` through `EventLoggerService`.\n\n== `productSummary`\n\n[cols=\"1,3\"]\n|===\n| Key | `productSummary`\n| Module | `ProductSummaryModule`\n| Purpose | The product offerings summary shown on the dashboard and the Product Offerings page.\n| Actions | `ProductSummaryActions.fetchProductSummaryData`, `…Success`, `…Error`\n|===\n\nRefetched by `DocusignVerificationService.refreshData()` after a contract is\nsigned in `Dashboard` mode.\n\n== `airwallexCards`\n\n[cols=\"1,3\"]\n|===\n| Key | `airwallexCards`\n| Module | `AirwallexCardsFeatureModule`\n| Purpose | The supplier's Airwallex-issued cards.\n| Actions | `loadAirwallexCards`, `loadAirwallexCardsSuccess`, `loadAirwallexCardsFailure`\n| Selectors | `selectAirwallexCardsLoaded`, `selectHasAirwallexCards`\n|===\n\n== `cardOrder`\n\n[cols=\"1,3\"]\n|===\n| Key | `cardOrder`\n| Module | `CardOrderFeatureModule`\n| Purpose | The card-ordering wizard behind `/newcard`.\n| Actions | `loadCardProviders`, `loadCardProvidersSuccess`, `loadCardProvidersFailure`, `orderCard`, `orderCardSuccess`, `orderCardFailure`\n|===\n\n`loadCardProviders` resolves which issuer (`CardProvider.Marqeta` or\n`CardProvider.Airwallex`) and which form factor (`CardFormFactor.Physical` or\n`Virtual`) the supplier is eligible for.\n\n== `page-load-time-tracker` (no store slice)\n\n`PageLoadTimeTrackerModule.forRoot(['/dashboard'])` registers services rather\nthan a reducer:\n\n[cols=\"2,3\"]\n|===\n| `PageLoadTimeTrackerService` | Measures navigation-to-render duration for allow-listed routes.\n| `PageLoadTimeNotifierService` | Emits completed measurements.\n| `page-load-time-initializer.factory.ts` | Bridges the notifier to `CloudWatchService`, and registers the Daily Summary load-error counter.\n|===\n\nSee xref:integrations/observability.adoc[Observability].\n== State type index\n\nSlices are registered by *key* but read through their exported *state interface*.\nThe two names rarely match, and only some slices export a distinctly named\ninterface — the rest export a bare `State` that callers alias on import\n(`import * as fromPlaid from './plaid.reducer'` then `fromPlaid.State`).\n\nUse this table when you have one name and need the other.\n\n[cols=\"2,2,2\"]\n|===\n| Directory | Store key | Exported state type\n\n| `features/advance-line/summary/` | `advanceLine` | `SummaryState`\n| `features/advance-line/offers/` | `advanceLineOffers` | `OffersState`\n| `features/airwallex-cards/` | `airwallexCards` | `AirwallexCardsState`\n| `features/balance/` | `balance` | `State`\n| `features/blog/` | `blog` | `BlogState`\n| `features/business-information/` | `businessInformation` | `State`\n| `features/card-order/` | `cardOrder` | `CardOrderState`\n| `features/dashboard-notifications/` | `dashboardNotifications` | `DashboardNotificationsState`\n| `features/email-preferences/` | `emailPreferences` | `EmailPreferencesState`\n| `features/fees/` | `fees` | `FeesState`\n| `features/instant-advance/` | `instantAdvance` | `InstantAdvanceState`\n| `features/instant-advance-repayment/` | `instantAdvanceRepayment` | `InstantAdvanceRepaymentState`\n| `features/payment-methods/` | `paymentMethods` | `PaymentMethodState`\n| `features/personal-information/` | `personalInformation` | `State`\n| `features/plaid/` | `plaid` | `State`\n| `features/product-summary/` | `productSummary` | `ProductSummaryState`\n| `features/two-factor-authentication/` | `2fa` | `TwoFAState`\n|===\n\nEntity collections (xref:state/entity-slices.adoc[Entity Slices]):\n\n[cols=\"2,2,2\"]\n|===\n| Directory | Store key | Exported state type\n\n| `entities/countries/` | `countries` | `CountriesState`\n| `entities/daily-summary-report/` | `reports.dailySummaryReports` | `State`\n| `entities/marketplaces/` | `marketplaces` | `State`\n| `entities/onboarding/` | `onboarding` | `State`\n| `entities/supplier-marketplaces/` | `supplierMarketplaces` | `SupplierMarketplacesState`\n|===\n\n[NOTE]\n====\nThree naming inconsistencies to be aware of:\n\n* `advance-line/` registers *two* slices from one directory, and the summary\n  slice's key (`advanceLine`) does not match its interface (`SummaryState`).\n* `two-factor-authentication/` uses the key `2fa` — a numeric-leading string,\n  so it must be quoted when indexing state by hand.\n* `daily-summary-report/` is the only dotted key\n  (`reports.dailySummaryReports`). It is a plain string, not a nested path;\n  NgRX does not split on the dot.\n\nNew slices should export a named interface (`MyFeatureState`) rather than a bare\n`State`, so it is greppable. See\nxref:development/adding-a-feature.adoc[Adding a Feature].\n====\n"},{"component":"supplier-client","module":"ROOT","path":"state/entity-slices.adoc","title":"Entity Slices","description":"The @ngrx/entity collections — adapters, id selectors, actions and consumers.","url":"https://docx.payability.com/supplier-client/state/entity-slices.html","navOrder":17,"source":"= Entity Slices\n:description: The @ngrx/entity collections — adapters, id selectors, actions and consumers.\n\nFive normalized collections live under `src/app/core/pyb/entities/`. Each uses\n`createEntityAdapter` with an explicit `selectId`, because the API keys are not\ncalled `id`.\n\n[cols=\"2,2,2\"]\n|===\n| Slice | Key | `selectId`\n\n| Countries | `countries` | `entity.countryCode`\n| Marketplaces | `marketplaces` | `entity.marketplaceKey`\n| Supplier Marketplaces | `supplierMarketplaces` | `entity.marketplaceKey`\n| Onboarding | `onboarding` | `entity.complianceKey`\n| Daily Summary Report | `reports.dailySummaryReports` | `entity.xactPostDate`\n|===\n\nEvery slice re-exports the adapter's selectors:\n\n[source,typescript]\n----\nexport const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();\n----\n\n== `countries`\n\n[cols=\"1,3\"]\n|===\n| Model | `CountryModel`\n| Module | `CountriesFeatureModule`\n| Purpose | Reference data for every address form and the country/phone pickers.\n| Actions | `fetchCountries`, `loadedCountriesSuccess`\n|===\n\n== `marketplaces`\n\n[cols=\"1,3\"]\n|===\n| Model | `MarketplaceModel`\n| Module | `MarketplacesFeatureModule`\n| Purpose | The catalog of *supported* marketplaces — names, logos, connection capabilities.\n| Config token | `MARKETPLACES_CONFIG_TOKEN` (declared in `marketplaces.tokens.ts`)\n|===\n\nActions:\n\n[cols=\"2,3\"]\n|===\n| Action | Purpose\n\n| `fetchMarketplaces` / `fetchMarketplacesSuccess` / `fetchMarketplacesError` | Load the catalog.\n| `openAmazonInviteWrapper` | Open the Amazon invite-Payability wizard.\n| `openAmazonPermissionWrapper` | Open the Amazon grant-permissions wizard.\n| `amazonSuspendedProcessBreak` | The supplier abandoned the Amazon-suspended flow.\n| `amazonSuspendedProcessCompleted` | The Amazon-suspended flow finished.\n|===\n\nMarketplace identities are fixed UUIDs in `MarketplaceEnum` — see\nxref:business/marketplaces.adoc[Marketplaces & Compliance].\n\n== `supplierMarketplaces`\n\n[cols=\"1,3\"]\n|===\n| Model | `MarketplaceModel` (same shape, supplier-scoped instances)\n| Module | `SupplierMarketplacesFeatureModule`\n| Purpose | The channels *this* supplier has connected, with per-channel connection health.\n|===\n\nActions:\n\n[cols=\"2,3\"]\n|===\n| Group | Actions\n\n| Load | `fetchSupplierMarketplaces`, `loadedSupplierMarketplaceSuccess`, `loadedSupplierMarketplaceError`\n| Add | `addSupplierMarketplace`, `addSupplierMarketplaceSuccess`\n| Bulk add | `bulkAddSupplierMarketplaces`, `bulkAddSupplierMarketplacesSuccess`, `bulkAddSupplierMarketplacesError`\n| Bulk add (unsupported) | `bulkAddUnsupportedSupplierMarketplaces`, `…Success`, `…Error`\n| Save | `saveSupplierMarketplace`, `saveSupplierMarketplaceSuccess`\n| Remove | `removeSupplierMarketplace`, `removeSupplierMarketplaceSuccess`, `removeSupplierMarketplaceError`\n|===\n\n[NOTE]\n====\n\"Unsupported\" marketplaces are channels a supplier reports selling on that\nPayability does not integrate with. They are recorded for underwriting context\nbut never produce a connector.\n====\n\nThis slice is the input to the API connectors — `ApiConnectorsService` dispatches\n`fetchSupplierMarketplaces()` on init and the Amazon and Shopify connectors watch\nit for connection loss. See\nxref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\n`DocusignVerificationService` also re-dispatches `fetchSupplierMarketplaces()`\nafter a successful Advance Line withdrawal, since drawing funds can change\nper-marketplace availability.\n\n== `onboarding`\n\n[cols=\"1,3\"]\n|===\n| Model | `PayabilityComplianceModel`\n| Module | `OnboardingFeatureModule`\n| Purpose | Onboarding progress, modelled as a set of *compliance* records keyed by `complianceKey`.\n| Actions | `initializeOnboarding`, `initializedOnboarding`, `fetchCompliances`, `loadedCompliances`\n|===\n\nEach compliance record is a step the supplier must satisfy. The keys are the\nUUIDs in `PayabilityComplianceEnum` — link marketplace, personal information,\noffer summary, sign an agreement, two-step verification, transfer information,\nbusiness information, KYC identity, personal details, sales call summary, bank\nstatement, other.\n\nSee xref:business/onboarding.adoc[Onboarding].\n\n== `reports.dailySummaryReports`\n\n[cols=\"1,3\"]\n|===\n| Model | `DailySummary` (from `@payability/pyb-ng2-api`)\n| Module | `DailySummaryReportModule`\n| Purpose | Paginated daily transaction summaries for the Daily Summary report.\n| `selectId` | `entity.xactPostDate` — one record per posting date\n|===\n\nThis is the one slice that still uses *class-based* actions rather than\n`createAction`:\n\n[source,typescript]\n----\nexport enum DailySummaryReportActionTypes {\n    DailySummaryReportsRequest     = '[DailySummaryReport] Request data',\n    DailySummaryReportsLoadSuccess = '[DailySummaryReport] Load data success',\n    DailySummaryReportsLoadFailed  = '[DailySummaryReport] Load data failed',\n    AddDailySummaryReport          = '[DailySummaryReport] Add DailySummaryReport',\n    AddDailySummaryReports         = '[DailySummaryReport] Add DailySummaryReports',\n    ClearDailySummaryReports       = '[DailySummaryReport] Clear DailySummaryReports',\n}\n\nexport class DailySummaryReportsRequest implements Action {\n    readonly type = DailySummaryReportActionTypes.DailySummaryReportsRequest;\n    constructor(public payload: { page: QueryParamsModel }) {}\n}\n\nexport class DailySummaryReportsLoadSuccess implements Action {\n    readonly type = DailySummaryReportActionTypes.DailySummaryReportsLoadSuccess;\n    constructor(public payload: { data: DailySummary[]; totalCount: number; page: QueryParamsModel }) {}\n}\n----\n\n[NOTE]\n====\nPaging is expressed with `QueryParamsModel` from `core/_base/crud`, which the AG\nGrid server-side datasource also speaks. This is why the slice carries the page\ndescriptor alongside the data.\n====\n\nLoad failures are counted as a CloudWatch metric via\n`initializeDailySummaryLoadDataErrorTrackerProvider` — see\nxref:integrations/observability.adoc[Observability].\n"},{"component":"supplier-client","module":"ROOT","path":"state/persistence.adoc","title":"State Persistence","description":"How selected store slices survive a page reload, and the pitfalls of the current implementation.","url":"https://docx.payability.com/supplier-client/state/persistence.html","navOrder":18,"source":"= State Persistence\n:description: How selected store slices survive a page reload, and the pitfalls of the current implementation.\n\nTwo slices are persisted to `localStorage` through a shared meta-reducer factory:\n`auth` and `marketplaces`. Everything else is re-fetched on load.\n\n== The meta-reducer factory\n\n`src/app/core/reducers/persistent-store.meta-reducer-factory.ts`\n\n[source,typescript]\n----\nexport const storageMetaReducer = <S, A extends Action = Action>(\n    saveKeys: string[],\n    localStorageKey: string,\n    storageService: PersistentStateManager,\n) => {\n    let onInit = true;\n    return (reducer: ActionReducer<S, A>) => (state: S, action: A): S => {\n        const nextState = reducer(state, action);\n\n        if (onInit) {                                              // <1>\n            onInit = false;\n            const savedState = storageService.getSavedState(localStorageKey);\n            return merge(nextState, savedState);                   // <2>\n        }\n\n        const stateToSave = pick(nextState, saveKeys);             // <3>\n        storageService.setSavedState(stateToSave, localStorageKey);\n        return nextState;\n    };\n};\n----\n<1> The very first action through the reducer is treated as hydration.\n<2> `lodash.merge` deep-merges the saved snapshot *over* the freshly computed\n    state, so persisted values win.\n<3> On every subsequent action, only the listed keys are written back.\n\n== `PersistentStateManager`\n\n`src/app/core/pyb/services/persistent-state-manager.service.ts`\n\n[source,typescript]\n----\n@Injectable({ providedIn: 'root' })\nexport class PersistentStateManager {\n    private storageKeys = {};\n\n    setSavedState(state: any, localStorageKey: string): void {\n        this.storageKeys[localStorageKey] = true;\n        localStorage.setItem(localStorageKey, JSON.stringify(state));\n    }\n\n    getSavedState(localStorageKey: string): any {\n        return JSON.parse(localStorage.getItem(localStorageKey));\n    }\n\n    clear() {\n        for (const key in this.storageKeys) {\n            if (typeof key === 'string') { localStorage.removeItem(key); }\n        }\n        this.storageKeys = {};\n    }\n}\n----\n\n`clear()` only removes keys this *instance* has written. It is called from\n`AuthEffects.logout$`, which is followed by an explicit\n`localStorage.removeItem(authTokenKey)` and a full page reload — belt and braces.\n\n== Persisted slices\n\n=== `auth`\n\nConfigured in `AuthModule`:\n\n[source,typescript]\n----\n{ provide: AUTH_LOCAL_STORAGE_KEY, useValue: '__auth_storage__' },\n{ provide: AUTH_STORAGE_KEYS,      useValue: ['auth'] },\n{\n    provide: AUTH_CONFIG_TOKEN,\n    deps: [AUTH_STORAGE_KEYS, AUTH_LOCAL_STORAGE_KEY, PersistentStateManager],\n    useFactory: getAuthPersistentStateFactory,\n},\n----\n\n[cols=\"1,3\"]\n|===\n| Storage key | `__auth_storage__`\n| Saved keys | `['auth']` — the `LoginState` sub-slice only\n| Not saved | `recoverPasswordRequest`, `newPasswordRequest` (transient form state)\n|===\n\nThis is what makes `AuthGuard` able to read `isLoggedIn` from the store on a cold\nload: the slice is rehydrated before the router evaluates guards.\n\n=== `marketplaces`\n\nConfigured in `MarketplacesFeatureModule`:\n\n[cols=\"1,3\"]\n|===\n| Storage key | `__marketplaces_storage__`\n| Saved keys | `['ids', 'entities']` — the `@ngrx/entity` collection itself\n| Not saved | Loading and error flags\n|===\n\nThe marketplace catalog is large, effectively static reference data, and needed\nearly to render the marketplace pages — caching it removes a blocking request\nfrom every page load.\n\n== The session token\n\nThe token is *not* part of the persisted slice. It is written directly by\n`AuthEffects.login$` and `register$` to `localStorage[environment.authTokenKey]`,\nand read by `AuthService.getAccessToken()` and `AuthEffects.init$`.\n\n== Other `localStorage` keys\n\n[cols=\"2,3\"]\n|===\n| Key | Owner / purpose\n\n| `environment.authTokenKey` | The session token.\n| `__auth_storage__` | Persisted `auth` slice.\n| `__marketplaces_storage__` | Persisted `marketplaces` entity collection.\n| `docusignFlowInitialized` | Replay guard for DocuSign returns. See xref:business/docusign.adoc[DocuSign Contract Flows].\n| `link_token` | Plaid Link token, read and removed by `AppModule.handlePlaid()`.\n|===\n\n== Caveats\n\n[WARNING]\n.Hydration merges, it does not replace\n====\n`merge(nextState, savedState)` means a *stale* persisted value overrides a fresh\none on the first action. Removing a field from a state interface does not remove\nit from previously persisted snapshots, and a renamed field leaves the old key\nmerged in. When changing the shape of a persisted slice, bump the storage key\n(for example `__auth_storage_v2__`) so old snapshots are ignored.\n====\n\n[WARNING]\n.The `onInit` flag is per-factory-call\n====\n`let onInit = true` is captured in the closure created by `storageMetaReducer(…)`.\nEach slice gets its own flag because each calls the factory separately. Reusing\none returned meta-reducer across two slices would hydrate only the first.\n====\n\n[WARNING]\n.Every action writes to `localStorage`\n====\nThe save branch runs on *every* dispatched action, serializing the picked keys\neach time. It is acceptable at the current slice sizes, but persisting a large\nor rapidly changing slice would put JSON serialization on the hot path.\n====\n\n[NOTE]\n.Persisted state is never trusted for authorization\n====\nOn boot, `AuthEffects.init$` re-enters the full login pipeline: the user is\nre-fetched from the API and permissions are recalculated by `PermissionsService`.\nTampering with `__auth_storage__` changes what renders for a fraction of a\nsecond, not what the API will authorize.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"business/domain-model.adoc","title":"Domain Model","description":"The entities the client reasons about — supplier, status, products, marketplaces — and the fields that drive behaviour.","url":"https://docx.payability.com/supplier-client/business/domain-model.html","navOrder":19,"source":"= Domain Model\n:description: The entities the client reasons about — supplier, status, products, marketplaces — and the fields that drive behaviour.\n\nAlmost every conditional in the application reads from one object: the `User`,\nand specifically `User.status`. This page is the field reference for that object.\n\n== `User`\n\n`src/app/core/auth/_models/user.model.ts`\n\n[cols=\"2,1,3\"]\n|===\n| Field | Type | Meaning\n\n| `id` | `string` | The *supplier key*. Passed to every supplier-scoped API call.\n| `schema` | `OnboardingSchemaEnum` | The onboarding path. Gates early product access.\n| `email` | `string` | Contact email.\n| `dateOfBirth` | `string` | Contact date of birth.\n| `accessToken` / `refreshToken` | `string` | Session token (both set to the same value).\n| `roles` | `number[]` | Legacy; always empty.\n| `permissions` | `PermissionsEnum[]` | Calculated permission set.\n| `pcardAvailable` | `boolean` | Seller Card explicitly enabled by Payability.\n| `ppmAvailable` | `boolean` | Instant Transfer (push-to-card) available.\n| `achAvailable` / `wirAvailable` / `efsAvailable` | `boolean` | Payout rails available to the supplier.\n| `plaidAccountMask` | `string` | Masked account number of the linked bank account.\n| `_personalInformation` | `PersonalInformation` | Name, DOB, phone, email, tax id, residential address.\n| `_businessInformation` | `BusinessInformation` | Legal name, business type, incorporation state, EIN, business address.\n| `status` | `Status` | The supplier state object — see below.\n| `dashboardStatus` | `SupplierDashboardStatus` | `{ status, enableRejectedNotification }`.\n| `cancelationStatus` | `CancelationStatus` | `canceled` \\| `pending` \\| `null`.\n| `onboardingTemplateCode` | `string \\| OnboardingTemplateCode` | Selects the onboarding template, e.g. `free_access_3.0`.\n| `amazonApiSwitching` | `boolean` | The supplier is mid-migration between Amazon API connection methods.\n| `canRequestInstantTransfer` | `boolean` | Server-side gate on requesting an instant transfer, independent of the permission.\n|===\n\n[WARNING]\n.Deprecated flat fields\n====\n`firstName`, `lastName`, `fullname`, `occupation`, `companyName`, `phone` and\n`address` are marked `@Deprecated` on the model. They duplicate values now owned\nby `_personalInformation` and `_businessInformation`. Read the nested objects.\n====\n\n== `Status`\n\n`src/app/core/auth/_models/status.model.ts`, built by `Status.fromDto(SupplierStatusDto)`.\n\n=== Lifecycle\n\n[cols=\"2,1,3\"]\n|===\n| Field | Type | Meaning\n\n| `supplierStatus` | `SupplierStatusType` | `Active` \\| `Pending` \\| `PendingActivation` \\| `Suspended`.\n| `enrollmentStart` | `string` | When enrolment began.\n| `suspensionReason` | `SuspensionReason` | Why the account is suspended.\n| `writeoff` | `boolean` | The account has been written off.\n|===\n\n=== Completeness flags\n\n[cols=\"2,1,3\"]\n|===\n| `legalSigned` | `boolean` | The supplier agreement is signed. Drives the re-sign flow.\n| `generalInfoUpdated` | `boolean` | General information supplied.\n| `buisnessDataCompleted` | `boolean` | Business data complete. (Spelling is as in the API.)\n| `sellerDataCompleted` | `boolean` | Seller data complete.\n| `hasMarketplaces` | `boolean` | At least one marketplace is connected.\n| `addendumSigned` / `achSigned` / `instantAdvanceSigned` | `boolean` | Individual contract signatures.\n|===\n\n=== Payments\n\n[cols=\"2,1,3\"]\n|===\n| `pmtCfgProvided` | `boolean` | Payment configuration submitted. Grants `TRANSFER_INFORMATION` while pending.\n| `pmtCfgStatus` | `string` | Payment configuration status.\n| `autodebitPmtCfg` | `AutodebitPaymentConfigInterface` | Autodebit configuration used by advance repayment.\n| `hasPaymentHistory` | `boolean` | At least one payment processed. Gates `REPORTS`, `DAILY_SUMMARY` and `SELLER_CARD`.\n| `nextPayment` | `string` | Next scheduled payment date.\n| `hasAutoTransferEnabled` | `boolean` | Automatic transfers are on.\n| `autoTransferMinimumBalanceToStay` | `number` | Floor retained when auto-transferring.\n| `hasForwardPaymentEnabled` | `boolean` | Vendor Pay enabled. Grants `VENDOR_PAY`.\n| `freeFundsTransferOnly` | `boolean` | Restricted to fee-free transfers.\n| `instantAccess` / `instantAccessActivated` | `boolean` | Instant Access product state.\n|===\n\n=== Advance products\n\n[cols=\"2,1,3\"]\n|===\n| `advanceLine` | `AdvanceLineStatusDto` | Status, UW status, `currentAmount`, `outstandingAmount`, `expirationDate`, `type`.\n| `activeInstantAdvance` | `InstantAdvanceReport` | The active Instant Advance, or `null`.\n| `hasInstantAdvHistory` | `boolean` | Ever had an Instant Advance. Grants the advance reports.\n| `hasReceivablePurchase` | `boolean` | Receivable purchase activity.\n| `supAdvStatus` / `supAdvLiability` | `string` | Supplier advance status and liability.\n| `fundPartStatus` | `string` | Funding participation status.\n| `signAPRAdvanceContract` | `SignAdvanceContractEnum` | Whether an APR contract must be signed. Backs `UserService.isAprUserContract()`.\n| `isProRataMVP` | `boolean` | Pro-rata MVP cohort.\n| `hasFlexEnabled` | `boolean` | Flex product enabled.\n|===\n\n=== Long Term Contract\n\n[cols=\"2,1,3\"]\n|===\n| `hasLTCHistory` | `boolean` | Ever signed an LTC. Grants `LCT_HISTORY`.\n| `hasActiveLTC` | `boolean` | An LTC is currently active.\n| `renewLTCSigned` | `boolean` | A renewal has been signed.\n| `LTCGenericContract` | `LongTermContractGenericContractInterface` | The generic contract descriptor.\n|===\n\n=== Other\n\n[cols=\"2,1,3\"]\n|===\n| `isFreeAccess` | `boolean` | Free-access cohort (`free_access_3.0` template).\n| `efsAccount` | `boolean` | EFS account exists.\n| `ascApiStatus` | `string` | Amazon Seller Central API connection status.\n| `calendly` | `string` | Scheduling link for a sales or support call.\n| `showReturnLink` | `boolean` | Show the return-to-partner link.\n|===\n\n== Status enums\n\n[source,typescript]\n----\n// @payability/pyb-ng2-api\nenum SupplierStatusType { Active, Pending, PendingActivation, Suspended }\n\n// core/auth/_models/status.model.ts\nexport enum SupplierDashboardStatusType {\n    Onboarding        = 'onboarding',\n    Welcome           = 'welcome',\n    InactiveDashboard = 'inactive_dashboard',\n    Active            = 'active',\n}\n\nexport enum CancelationStatus {\n    Canceled = 'canceled',\n    Pending  = 'pending',\n}\n----\n\n[NOTE]\n====\nThere are two dashboard-status types. The local `SupplierDashboardStatusType`\nabove drives permission calculation via `isUserDuringOnboarding()`. The API also\nreturns `SupplierDashboardStatusTypeV2` (`Active`, `Suspended`, …), stored as\n`dashboardStatusV2` on the auth slice and read by `GlobalSubscriptionsService`.\n====\n\n== Product matrix\n\n[cols=\"2,2,3\"]\n|===\n| Product | Availability signal | Documented in\n\n| Advance Line | `status.advanceLine` with `type !== Legacy` | xref:business/advance-line.adoc[Advance Line]\n| Instant Advance | `status.activeInstantAdvance` / `hasInstantAdvHistory` | xref:business/instant-advance.adoc[Instant Advance]\n| Long Term Contract | `hasActiveLTC` / `hasLTCHistory` | xref:business/long-term-contract.adoc[Long Term Contract]\n| Instant Transfer | `user.ppmAvailable` + `canRequestInstantTransfer` | xref:business/instant-transfer.adoc[Instant Transfer]\n| Seller Card | `pcardAvailable` or US addresses, plus payment history | xref:business/seller-card.adoc[Seller Card]\n| Vendor Pay | `hasForwardPaymentEnabled` | xref:business/vendor-pay.adoc[Vendor Pay]\n|===\n\n== Marketplace entities\n\n[cols=\"2,3\"]\n|===\n| `MarketplaceModel` | A marketplace, keyed by `marketplaceKey`. Used for both the global catalog and the supplier's own connections.\n| `SupplierMarketplaceModel` | A supplier's connection to a marketplace, including connection health.\n| `PayabilityComplianceModel` | One onboarding/compliance requirement, keyed by `complianceKey`.\n| `CountryModel` | Country reference data, keyed by `countryCode`.\n|===\n\nSee xref:business/marketplaces.adoc[Marketplaces & Compliance].\n\n== Reading the model safely\n\nStatus fields are optional and frequently `null` before the status call resolves.\nProduction code uses optional chaining throughout:\n\n[source,typescript]\n----\nconst hasValidActiveAdvance =\n    status.activeInstantAdvance &&\n    (!status.advanceLine || status.advanceLine?.type === AdvanceLineModeEnum.Legacy);\n----\n\n`statusLoading` on the auth slice indicates a status refresh is in flight; guards\nand widgets that must not render against stale data wait for it to settle.\n"},{"component":"supplier-client","module":"ROOT","path":"business/supplier-lifecycle.adoc","title":"Supplier Lifecycle","description":"How a supplier moves through registration, underwriting, activation, suspension and cancellation — and what changes at each transition.","url":"https://docx.payability.com/supplier-client/business/supplier-lifecycle.html","navOrder":20,"source":"= Supplier Lifecycle\n:description: How a supplier moves through registration, underwriting, activation, suspension and cancellation — and what changes at each transition.\n\n== States\n\n[listing]\n----\n        register\n            │\n            ▼\n      ┌──────────┐   underwriting     ┌────────────────────┐\n      │ Pending  │──────approves─────▶│ PendingActivation  │\n      └──────────┘                    └────────────────────┘\n            │                                   │\n            │  underwriting                     │  activation completes\n            │  rejects                          │  (contract signed)\n            ▼                                   ▼\n      (application                        ┌──────────┐\n       closed)                            │  Active  │\n                                          └──────────┘\n                                            │      ▲\n                       policy violation,    │      │  reinstatement\n                       lost API access,     │      │\n                       failed payments      ▼      │\n                                          ┌───────────┐\n                                          │ Suspended │\n                                          └───────────┘\n                                                │\n                                cancellation confirmed\n                                                ▼\n                        cancelationStatus = Canceled (+ Suspended)\n----\n\n`supplierStatus` values come from `SupplierStatusType` in\n`@payability/pyb-ng2-api`. Cancellation is *not* a fifth status — it is the\ncombination `cancelationStatus === Canceled` **and**\n`supplierStatus === Suspended`, which is exactly what `selectIsSupplierCancelled`\nchecks.\n\n== What each state unlocks\n\n[cols=\"1,2,2\"]\n|===\n| State | Gains | Loses / never had\n\n| `Pending`\n| Onboarding routes. With the `Expedited` schema: `ADVANCE_LINE_PENDING_ACTIVATION`\n  and `VIEW_ADVANCE_LINE_WIDGET`. `TRANSFER_INFORMATION` once `pmtCfgProvided`.\n| `TRANSFER_FUNDS`. `VIEW_NOTIFICATIONS` when pending *and* expedited.\n\n| `PendingActivation`\n| Same as `Pending` — both are covered by `isUserPending()`.\n| Same as `Pending`.\n\n| `Active`\n| `TRANSFER_FUNDS`, `TRANSFER_INFORMATION`, `REFERRAL`.\n  With payment history: `REPORTS`, `DAILY_SUMMARY`, `SELLER_CARD`.\n| —\n\n| `Suspended`\n| Keeps `TRANSFER_FUNDS` — money already owed remains accessible.\n| `WITHDRAW_ADVANCE_LINE` is explicitly denied while suspended.\n\n| Cancelled\n| A read-only view plus the reactivation path.\n| Product actions.\n|===\n\n== Transition triggers in the client\n\nThe client never sets `supplierStatus` — it re-reads it. Every action that could\nchange eligibility ends with:\n\n[source,typescript]\n----\nthis.store.dispatch(userStatusUpdateRequested());\n----\n\nwhich runs `getSupplierStatus(id)` → `userStatusUpdated` → `userPermissionsRequested`\n→ recalculation. Call sites include:\n\n* completing any DocuSign flow,\n* completing an Advance Line withdrawal,\n* accepting an Instant Advance,\n* finishing onboarding activation,\n* `UpdateUserStatusTask` inside any composed flow.\n\n== The dashboard status axis\n\n`dashboardStatus.status` is orthogonal to `supplierStatus` and decides *which\nshell* the supplier sees.\n\n[cols=\"2,3\"]\n|===\n| Value | Meaning\n\n| `onboarding` | Still in the onboarding funnel. Suppresses `REPORTS`, `DAILY_SUMMARY`, `SELLER_CARD` and `REFERRAL` regardless of other flags.\n| `welcome` | Onboarding finished; the welcome experience has not been dismissed.\n| `inactive_dashboard` | Account exists but the full dashboard is not available.\n| `active` | The normal dashboard.\n|===\n\n`OnboardingComponent` reads this value to route the supplier to the correct step,\nand `DashboardStatusGuard` uses it to push onboarding users back to `/dashboard`.\n\n== Cancellation and reactivation\n\n`cancelationStatus` moves `null` → `Pending` → `Canceled`. The client surfaces\nthis through `CancellationBannerInitializerService` and the cancellation survey.\nSee xref:business/cancellation.adoc[Cancellation & Reactivation].\n\n== Suspension\n\n`suspensionReason` carries a `sh.susp:*` code that maps to specific copy and, in\nseveral cases, a specific remediation CTA. See\nxref:business/suspension.adoc[Suspension].\n\n== Onboarding schema, an orthogonal axis\n\n[cols=\"2,2,3\"]\n|===\n| Schema | Value | Effect on the lifecycle\n\n| `Base` | `3.0` | Standard path.\n| `WithPlaid` | `3.0_plaid` | Standard path plus mandatory Plaid bank linking.\n| `Expedited` | `exp_onb` | Advance Line products available while `Pending` / `PendingActivation`.\n| `AmazonExpedited` | `amzn_exp_onb` | Amazon-specific expedited variant.\n| `Next` | `NEXT` | Next-generation onboarding.\n|===\n\n[WARNING]\n====\n`PermissionsService.isUserPendingAndExpedited()` compares against\n`OnboardingSchemaEnum.Expedited` **only**. `AmazonExpedited` suppliers do *not*\nreceive early Advance Line permissions from that predicate, despite the name.\n====\n\n== Impersonation\n\nStaff entering through `/auth/auth` (`LoginAsSupplierComponent`) see exactly the\nstate described above — the client computes permissions from the impersonated\nsupplier's real `User` object, with no bypass.\n"},{"component":"supplier-client","module":"ROOT","path":"business/onboarding.adoc","title":"Onboarding","description":"Schemas, compliance records, the pre-activation status bar, the four onboarding steps, and account activation.","url":"https://docx.payability.com/supplier-client/business/onboarding.html","navOrder":21,"source":"= Onboarding\n:description: Schemas, compliance records, the pre-activation status bar, the four onboarding steps, and account activation.\n\nOnboarding takes a new supplier from registration to an active account. It lives\nat `/onboarding` under its own shell (`OnboardingComponent`) and is tracked by\ntwo independent mechanisms: the *compliance entity slice* and the\n*pre-activation status bar*.\n\n== Onboarding schemas\n\nEvery supplier is assigned a schema at registration. It decides which steps\nappear and which products are reachable before activation.\n\n[source,typescript]\n----\n// src/app/core/pyb/enums/onboarding-schema.enum.ts\nexport enum OnboardingSchemaEnum {\n    Base            = '3.0',\n    WithPlaid       = '3.0_plaid',\n    Expedited       = 'exp_onb',\n    AmazonExpedited = 'amzn_exp_onb',\n    Next            = 'NEXT',\n}\n----\n\n[cols=\"2,1,3\"]\n|===\n| Schema | Value | Behaviour\n\n| `Base` | `3.0` | Standard multi-step onboarding.\n| `WithPlaid` | `3.0_plaid` | Standard plus mandatory Plaid bank linking.\n| `Expedited` | `exp_onb` | Advance Line accessible while `Pending` / `PendingActivation`.\n| `AmazonExpedited` | `amzn_exp_onb` | Amazon-specific expedited variant.\n| `Next` | `NEXT` | Next-generation flow.\n|===\n\n[IMPORTANT]\n====\nOnly `OnboardingSchemaEnum.Expedited` satisfies\n`PermissionsService.isUserPendingAndExpedited()`. `AmazonExpedited` does not,\neven though its name suggests otherwise. See\nxref:security/permissions.adoc[Permissions System].\n====\n\n== Onboarding templates\n\n`onboardingTemplateCode` selects the copy and step set independently of the\nschema:\n\n[source,typescript]\n----\nexport enum OnboardingTemplateCode {\n    FreeAccess = 'free_access_3.0',\n}\n----\n\nThe free-access template pairs with `status.isFreeAccess` and the\nfree-access product-offering components in\n`views/onboarding/product-offering/new-free-access/`.\n\n== Route structure\n\n[source,typescript]\n----\nconst routes: Routes = [\n    {\n        path: '',\n        component: OnboardingComponent,\n        children: [\n            { path: 'repayment-information', loadChildren: () => import('./repayment-information/…') },\n            { path: 'underwriting-review',   loadChildren: () => import('./underwriting-review/…') },\n            { path: 'product-offering',      loadChildren: () => import('./product-offering/…') },\n            { path: 'sign-agreement',        loadChildren: () => import('./sign-agreement/…') },\n        ],\n    },\n];\n----\n\n`OnboardingComponent` itself is a bare `<router-outlet>` host with the top bar —\nstep selection is driven by `TopBarService` and the router, not by branching\ninside the component.\n\n== Compliance records\n\nOnboarding progress is modelled as a set of *compliance* records in the\n`onboarding` entity slice, keyed by `complianceKey`.\n\n[source,typescript]\n----\nexport const initializeOnboarding  = createAction('[Onboarding] Initialize');\nexport const initializedOnboarding = createAction('[Onboarding] Initialized');\nexport const fetchCompliances      = createAction('[Onboarding] Fetch compliances');\nexport const loadedCompliances     = createAction('[Onboarding] LoadedCompliances',\n                                                  props<{ compliances: PayabilityComplianceModel[] }>());\n----\n\nThe keys are fixed UUIDs in `PayabilityComplianceEnum`:\n\n[cols=\"2,3\"]\n|===\n| Member | Requirement\n\n| `LinkMarketplace` | Connect at least one sales channel.\n| `PersonalInformation` | Contact details.\n| `PersonalDetails` | Extended personal details.\n| `BusinessInformation` | Legal entity details.\n| `KycIdentity` | Identity verification.\n| `BankStatement` | Bank statement upload.\n| `TransferInformation` | Payout configuration.\n| `TwoStepVerification` | 2FA enrolment.\n| `OfferSummary` | Review the product offer.\n| `SignAnAgreement` | Sign the supplier agreement.\n| `SalesCallSummary` | Sales call completed.\n| `Other` | Catch-all requirement.\n|===\n\n`DoctypeComplianceEnum.Documentation` covers generic document uploads.\n\nEach compliance carries an `AccessStatusEnum` value:\n\n[source,typescript]\n----\nexport enum AccessStatusEnum {\n    Pending    = 'pending',\n    Incomplete = 'incomplete',\n    Rejected   = 'rejected',\n    Completed  = 'completed',\n    Verified   = 'verified',\n}\n----\n\n== The pre-activation status bar\n\n`TopBarService` (`views/onboarding/top-bar/top-bar.service.ts`) drives the\nprogress stepper shown above every onboarding page. It calls\n`OnboardingService.getPreActivationStatus(supplierKey)` and maps the returned\nsteps into `OnbTopBarStep` objects with a translated title and a deep link.\n\n[cols=\"2,2,2\"]\n|===\n| Step key | Title key | Link\n\n| `yourDetails` | `YOUR_DETAILS` | `/manage-account`\n| `marketplacesAndPlatforms` | `MARKETPLACES_AND_PLATFORMS` | `/my-marketplaces`\n| `submitApplication` | `SUBMIT_APPLICATION` | `/onboarding/underwriting-review`\n| `submitted` | `SUBMITTED` | —\n| `underwritingReview` | `UNDERWRITING_REVIEW` | `/onboarding/underwriting-review`\n| `salesCallSummary` | `SALESCALLSUMMARY` | `/sales-call-summary`\n| `readyForActivation` | `READY_FOR_ACTIVATION` | —\n|===\n\n`salesCallSummary` is a client-side addition on top of the API's\n`PreActivationStatusStepsKeys`, expressed as\n`ExtendedPreActivationStatusStepsKeys`.\n\nObservables exposed by the service:\n\n[cols=\"2,3\"]\n|===\n| `steps$` | The mapped step list.\n| `details$` | `TopBarDetails` — `showStatusBar`, `isApplicationSubmitted`, …\n| `isSupplierIsInOnboarding$` | `showStatusBar && !isApplicationSubmitted`.\n| `title$` / `description$` | Page-supplied heading text.\n|===\n\n`fetchStatus()` is called from the constructor and re-called after\n`submitApplication()` and after onboarding activation.\n\n== Submitting the application\n\n`TopBarService.submitApplication()` shows a determinate loading dialog with a\nsynthetic progress animation while the API call runs:\n\n[source,typescript]\n----\nconst dialogRef = this.dialog.open<LoadingPageComponent, LoadingPageData>(LoadingPageComponent, {\n    disableClose: true,\n    data: { mode: 'determinate' },\n});\n\nconst minInMs = 1000 * 60;\nconst intervalStep = (0.25 * minInMs) / 100;      // <1>\nconst timer = interval(intervalStep).subscribe(() => {\n    dialogRef.componentInstance.loadingProgressValue = timerValue++;\n});\n\nthis.store.pipe(\n    select(selectCurrentUserId),\n    switchMap(supplierKey => this.onboardingService.submitApplication(supplierKey)),\n).subscribe(\n    () => this.fetchStatus(),\n    () => dialogRef.close(),\n);\n----\n<1> The progress bar is timed to fill over roughly 15 seconds. It is cosmetic —\n    the dialog closes when `loaded` fires, not when the bar reaches 100.\n\nThe dialog is `disableClose: true`, so the supplier cannot dismiss it mid-submit.\nOn error the dialog is closed and the supplier stays on the page.\n\n== The four steps\n\n=== Repayment Information — `/onboarding/repayment-information`\n\nCollects payment configuration: bank account details, or Plaid linking on the\n`WithPlaid` schema. Sets `status.pmtCfgProvided`, which grants\n`TRANSFER_INFORMATION` even while the supplier is pending.\n\n=== Underwriting Review — `/onboarding/underwriting-review`\n\nThe waiting state while underwriting assesses the application. Emits\n`OnboardingEventType.LoadedApplicationStatus` on load, and hosts the\n\"Submit application\" action.\n\n=== Product Offering — `/onboarding/product-offering`\n\nPresents the approved products. Components live in\n`views/onboarding/product-offering/`:\n\n[cols=\"2,3\"]\n|===\n| `advance-line/` | Advance Line offer presentation.\n| `instant-advance/` | Instant Advance offer.\n| `instant-access/` | Instant Access offer.\n| `access-plus/` | Access Plus offer.\n| `new-free-access/` | Free-access template variant.\n| `common-product-offer/` | Shared offer card shell.\n| `repayment-slider/` | Interactive repayment-term selector.\n| `item-with-label/` | Labelled feature row inside an offer card.\n| `wrappers/` | Layout wrappers per offer type.\n|===\n\n=== Sign Agreement — `/onboarding/sign-agreement`\n\nRedirects to DocuSign with `DocusignFlowEnum.OnboardingActivation`. See\nxref:business/docusign.adoc[DocuSign Contract Flows].\n\n== Activation\n\nOn return from DocuSign with `signing_complete`,\n`DocusignVerificationService.handleOnboardingActivation()` runs:\n\n[source,typescript]\n----\nprivate openOnboardingActivationModal(supplierKey: string): void {\n    this.onboardingService.startActivationAccount(supplierKey).pipe(\n        map(() => this.topBarService.fetchStatus()),\n        map(() => this.matDialog.open<LoadingPageComponent, LoadingPageData>(LoadingPageComponent, {\n            data: { activatingAccount: true, mode: 'indeterminate' },\n        })),\n    ).subscribe(() => {});\n}\n----\n\n`userStatusUpdateRequested()` is dispatched first, so permissions are recalculated\nas soon as the backend flips the supplier to `Active`.\n\n== Expedited path\n\nExpedited suppliers skip the underwriting wait for the *Advance Line*\nspecifically:\n\n[listing]\n----\nregister (schema = exp_onb)\n  → repayment information\n  → status Pending, advanceLine.status Pending\n      → permission ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION granted\n      → Advance Line widget renders in \"pending activation\" state\n      → first draw triggers DocusignFlowEnum.AdvanceLinePending (ACH agreement)\n      → on return the permission is revoked and the draw executes\n  → product selection + sign agreement continue as normal\n----\n\nThe full mechanics are in xref:business/advance-line.adoc[Advance Line].\n\n== Loading page\n\n`views/onboarding/loading-page/` provides `LoadingPageComponent`, used in two\nmodes:\n\n[cols=\"2,3\"]\n|===\n| `{ mode: 'determinate' }` | Application submission, with an animated progress value.\n| `{ activatingAccount: true, mode: 'indeterminate' }` | Post-DocuSign account activation.\n|===\n\n== Analytics\n\n[cols=\"2,3\"]\n|===\n| Event | When\n\n| `OnboardingEventType.SubmittedApplication` | The application is submitted.\n| `OnboardingEventType.LoadedApplicationStatus` | The underwriting-review page loads.\n| `LinkMarketplaceEventType.*` | Marketplace-linking sub-flow — see xref:analytics/event-catalog.adoc[Event Catalog].\n| `InviteMarketplaceEventType.*` | Invite-Payability sub-flow.\n|===\n\n== Related pages\n\n* xref:business/marketplaces.adoc[Marketplaces & Compliance]\n* xref:business/docusign.adoc[DocuSign Contract Flows]\n* xref:business/supplier-lifecycle.adoc[Supplier Lifecycle]\n* xref:integrations/plaid.adoc[Plaid]\n"},{"component":"supplier-client","module":"ROOT","path":"business/docusign.adoc","title":"DocuSign Contract Flows","description":"All six signing flows, the double replay guard, and what each flow does on return.","url":"https://docx.payability.com/supplier-client/business/docusign.html","navOrder":22,"source":"= DocuSign Contract Flows\n:description: All six signing flows, the double replay guard, and what each flow does on return.\n\nDocuSign is used wherever a legally binding signature is required. Because\nsigning happens on DocuSign's own domain, every flow is a *round trip out of the\napplication and back*, which is what makes this subsystem unusual.\n\nOrchestrated by `DocusignVerificationService`\n(`src/app/core/pyb/services/docusign-verification.service.ts`), which extends\n`Subscriber`.\n\n== Flow types\n\n[source,typescript]\n----\n// src/app/core/pyb/models/docusign-parameters.model.ts\nexport enum DocusignFlowEnum {\n    AdvanceLineActive    = 'advance-line-active',\n    LongTermContract     = 'long-term-contract',\n    AdvanceLinePending   = 'advance-line-pending',\n    InstantAdvanceActive = 'instant-advance-active',\n    ReAssignContract     = 're-assign-contract',\n    OnboardingActivation = 'onboarding-activation',\n}\n\nexport enum DocusignEventEnum {\n    SingingComplete = 'signing_complete',\n    Cancel          = 'cancel',\n    Decline         = 'decline',\n}\n\nexport enum DocusignModeEnum {\n    Dashboard            = 'dashboard',\n    ProductOfferingsList = 'product-offerings-list',\n}\n----\n\n[NOTE]\n====\n`SingingComplete` is spelled that way in both the local enum and the API\npackage's `DocusignEventTypeEnum`. Do not \"fix\" it — the string value\n`signing_complete` is what DocuSign sends, and the member name is referenced\nacross the codebase.\n====\n\n== Round trip\n\n[listing]\n----\n1  App builds a return URL\n        DocusignVerificationService.generateRedirectUrl(params, pageName)\n        → https://app.payability.com/#/dashboard?flowType=…&event=…&mode=…&…\n\n2  App sets the replay flag and leaves\n        redirectToDocusign(url)\n          localStorage['docusignFlowInitialized'] = 'true'\n          window.location.href = url\n\n3  Supplier signs / cancels / declines on DocuSign\n\n4  DocuSign redirects back to the return URL\n\n5  DocuSignGuard sees `event` + `flowType` in the query params\n        → initializeFlow(flowType, { supplierKey, event, data: queryParams })\n\n6  initializeFlow checks localStorage['docusignFlowInitialized']\n        absent → do nothing (direct navigation / refresh)\n        present → clearFlowInitialized() and run the flow\n\n7  Flow confirms with the API, executes the business action,\n   refreshes state and shows the outcome modal\n\n8  DocuSignGuard redirects to a clean URL so the params cannot be replayed\n----\n\n=== The two replay guards\n\nBoth must pass before any business action runs:\n\n[cols=\"1,3\"]\n|===\n| `DocuSignGuard.docusignFlowInitialized(queryParams)`\n| Requires *both* `event` and `flowType` query parameters — the signature of a\n  DocuSign redirect.\n\n| `DocusignVerificationService.docusignFlowInitialized()`\n| Requires the `docusignFlowInitialized` key in `localStorage`, which is only\n  ever written immediately before leaving for DocuSign.\n|===\n\n[IMPORTANT]\n====\nThis is why bookmarking a DocuSign return URL, or refreshing after returning,\ncannot re-execute a withdrawal. The URL alone is not enough — the\n`localStorage` flag is single-use and cleared the moment the flow starts.\n====\n\n== Redirect URL construction\n\n[source,typescript]\n----\npublic generateRedirectUrl<T>(params: T, pageName = 'dashboard'): string {\n    const paramString = Object.keys(params).map(key => `${key}=${params[key]}`).join('&');\n    return `${window.location.href.split('#')[0]}#/${pageName}?${paramString}`;\n}\n----\n\nThe `split('#')[0]` strips the current hash route so the return URL is built from\nthe origin plus a fresh hash path. This depends on hash routing being enabled.\n\n[WARNING]\n====\nParameter values are not URL-encoded. Any value containing `&`, `=` or `#` will\ncorrupt the query string.\n====\n\n== Handler parameters\n\n[source,typescript]\n----\nexport interface DocusignHandlerParams<D = any> {\n    supplierKey: string;\n    event: DocusignEventTypeEnum;\n    data?: D;\n}\n\nexport interface LtcDocusignHandlerParamsData {\n    duration: string;\n    discountedFeePercent: string;\n    contractActionType: LtcContractActionType;\n    mode: DocusignModeEnum;\n}\n----\n\n== Dispatch\n\n[source,typescript]\n----\npublic initializeFlow(flowType: DocusignFlowEnum, additionalParams: DocusignHandlerParams): Observable<boolean> {\n    switch (flowType) {\n        case DocusignFlowEnum.AdvanceLineActive:    return this.initializeAdvancedLineFlow(additionalParams);\n        case DocusignFlowEnum.LongTermContract:     return this.initializeLongTermContractFlow(additionalParams);\n        case DocusignFlowEnum.AdvanceLinePending:   return this.initializeAdvanceLinePendingFlow(additionalParams);\n        case DocusignFlowEnum.InstantAdvanceActive: return this.initializeInstantAdvanceFlow(additionalParams);\n        case DocusignFlowEnum.ReAssignContract:     return this.initializeReAssignContractFlow(additionalParams);\n        case DocusignFlowEnum.OnboardingActivation: return this.initializeOnboardingActivationFlow(additionalParams);\n        default: return throwError(`Flow type not supported: ${flowType}`);\n    }\n}\n----\n\n[NOTE]\n.Two classes of flow\n====\nThe advance flows (`AdvanceLineActive`, `AdvanceLinePending`,\n`InstantAdvanceActive`) additionally require\n`event === DocusignEventTypeEnum.SingingComplete` before doing anything — a\ncancel or decline is a no-op.\n\nThe contract flows (`LongTermContract`, `ReAssignContract`,\n`OnboardingActivation`) run their handler on *any* event, and branch on\n`SingingComplete` inside the handler. The confirmation call is made either way,\nso the backend learns the supplier declined.\n====\n\n== Flow 1 — Advance Line Active (withdraw)\n\nTriggered when a supplier with an active Advance Line draws funds.\n\n[source,typescript]\n----\nhandleAdvanceLineFlow = (params: DocusignHandlerParams): Observable<boolean> => {\n    const { supplierKey, event, data: { advanceLineKey, amount, offerType } } = params;\n    this.store.dispatch(clearDashboardNotifications());\n\n    return this.docusignService\n        .confirmAdvanceLineDocuSignRequest(supplierKey, { event }, this.userService.isAprUserContract())\n        .pipe(\n            tap(() => this.eventLogger.track({ eventMessage: AdvanceLineEventTypes.DOCUSIGN_VERIFIED })),\n            switchMap(() => this.advanceLineService\n                .withdraw(supplierKey, advanceLineKey, amount, offerType)\n                .pipe(tap(() => this.reloadCurrentState(offerType, amount)))),\n            map(() => true),\n        );\n};\n----\n\n`reloadCurrentState()` then:\n\n[source,typescript]\n----\nthis.store.dispatch(fetchSupplierMarketplaces());\nthis.store.dispatch(loadBalance());\nthis.store.dispatch(loadSummary());\nthis.store.dispatch(advanceLineWithdrawSuccess());\nthis.store.dispatch(userStatusUpdateRequested());\nthis.eventLogger.track({\n    eventMessage: AdvanceLineEventTypes.FUNDS_WITHDRAWN,\n    additionalProperties: { offerType, amount },\n});\nthis.showSuccessWithdrawModal(amount);   // FEATURES.ADVANCE_LINE.MODALS.SUCCESS.*\n----\n\n== Flow 2 — Advance Line Pending (ACH agreement)\n\nFor expedited suppliers signing their ACH agreement. The handler branches on\nwhether an `amount` was supplied:\n\n[source,typescript]\n----\nreturn this.isNumeric(params?.data?.amount)\n    ? this.handleAdvanceLinePendingFlow(params)                // draw funds now\n    : this.handleAdvanceLinePendingFlowInstantAdvance(params); // ACH only\n----\n\n*With an amount* — `confirmDocuSignAchRequest()`, revoke\n`ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION`, then `withdraw()` and the same\n`reloadCurrentState()` as flow 1.\n\n*Without an amount* — `confirmDocuSignAchRequest()`, revoke the permission, and\ndispatch `userStatusUpdateRequested()`. No withdrawal.\n\n== Flow 3 — Instant Advance Active\n\n[source,typescript]\n----\nhandleInstantAdvanceFlow = (params: DocusignHandlerParams): Observable<boolean> => {\n    const { supplierKey, event } = params;\n    this.store.dispatch(clearDashboardNotifications());\n    return this.docusignService.confirmAdvanceLineDocuSignRequest(supplierKey, { event }).pipe(\n        map(() => {\n            this.permissionService.removePermission('INSTANT_ADVANCE_PENDING');\n            this.store.dispatch(userStatusUpdateRequested());\n            this.messageModalService.openSuccessModal({\n                title:       this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.TITLE'),\n                description: this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.CONTENT'),\n            });\n            return true;\n        }),\n    );\n};\n----\n\nNote this call omits the APR flag, unlike the Advance Line flows.\n\n== Flow 4 — Long Term Contract\n\n[source,typescript]\n----\nhandleLongTermContract = (params: DocusignHandlerParams<LtcDocusignHandlerParamsData>) => {\n    const { supplierKey, event, data } = params;\n    return this.docusignService.confirmLongTermContractDocuSignRequest(supplierKey, { event }).pipe(\n        map(() => {\n            if (event === DocusignEventTypeEnum.SingingComplete) {\n                this.store.dispatch(clearDashboardNotifications());\n                this.refreshData(data?.mode);\n                this.openLtcDocusignSummaryModal(data);\n            }\n            return true;\n        }),\n    );\n};\n----\n\n`LtcSummaryModalComponent` receives `{ duration, discountedFeePercent,\ncontractActionType, mode }` and its close action decides the destination:\n\n[source,typescript]\n----\nprivate getUrlToRedirectAfterReturnFromDocuSign(type: LtcSummaryModalActionType): string {\n    switch (type) {\n        case LtcSummaryModalActionType.GoToReportsPage:          return `/${URLS_ROUTER.REPORTS.CONTRACTS_OVERVIEW_REPORT}`;\n        case LtcSummaryModalActionType.GoToProductOfferingsPage: return `/${URLS_ROUTER.PRODUCT_OFFERINGS.ROOT}`;\n        default:                                                 return `/${URLS_ROUTER.DASHBOARDS.ROOT}`;\n    }\n}\n----\n\nSee xref:business/long-term-contract.adoc[Long Term Contract].\n\n== Flow 5 — Re-Assign Contract\n\n[source,typescript]\n----\nhandleReAssignContract = (params: DocusignHandlerParams) => {\n    const { supplierKey, event } = params;\n    return this.docusignService.confirmReAssignDocuSignRequest(supplierKey, { event }).pipe(\n        map(() => {\n            this.store.dispatch(userStatusUpdateRequested());\n            this.refreshData(params.data?.mode);\n            if (event === DocusignEventTypeEnum.SingingComplete) {\n                this.openReAssignContractSummaryModal();   // MODALS.LEGAL_INFO_CONFIRM.AGREEMENT_SIGNED\n            }\n            return true;\n        }),\n    );\n};\n----\n\nThe flow is started by `ReSignContractFlow`, which `GlobalSubscriptionsService`\nlaunches when:\n\n[source,typescript]\n----\ncanResignContract({ isLegalSigned, userStatus, onboardSchema }): boolean {\n    return !isLegalSigned\n        && userStatus  === SupplierStatusType.Pending\n        && onboardSchema !== OnboardingSchemaEnum.Expedited;\n}\n----\n\nand the supplier is not mid-onboarding and has not just signed. If the supplier\ndismisses the flow (`FlowTaskResultType.Terminated`) a persistent banner built by\n`reSignContractBannerFactory` offers to restart it.\n\n== Flow 6 — Onboarding Activation\n\n[source,typescript]\n----\nhandleOnboardingActivation = (params: DocusignHandlerParams) => {\n    const { supplierKey, event } = params;\n    return this.docusignService.confirmOnboardingActivationDocuSignRequest(supplierKey, { event }).pipe(\n        map(() => {\n            this.store.dispatch(userStatusUpdateRequested());\n            if (event === DocusignEventTypeEnum.SingingComplete) {\n                this.openOnboardingActivationModal(supplierKey);\n            }\n            return true;\n        }),\n    );\n};\n----\n\nwhich calls `OnboardingService.startActivationAccount()`,\n`TopBarService.fetchStatus()`, and opens `LoadingPageComponent` with\n`{ activatingAccount: true, mode: 'indeterminate' }`.\n\n== `refreshData(mode)`\n\nWhat gets reloaded after a contract flow depends on where the supplier came from:\n\n[source,typescript]\n----\nprivate refreshData(mode: DocusignModeEnum) {\n    switch (mode) {\n        case DocusignModeEnum.ProductOfferingsList:\n            this.productOfferingsService.loadProducts();\n            break;\n\n        case DocusignModeEnum.Dashboard:\n        default:\n            this.store.select(selectDashboardNotifications)\n                .pipe(distinctUntilChanged(), skip(1), take(1))   // <1>\n                .subscribe(() => this.store.dispatch(ProductSummaryActions.fetchProductSummaryData()));\n            this.store.dispatch(fetchDashboardNotifications());\n            break;\n    }\n}\n----\n<1> `skip(1)` ignores the current value and waits for the *next* notification\n    emission, so the product summary is only refetched once the notifications\n    that drive it have actually changed.\n\n== APR contracts\n\n`UserService.isAprUserContract()` (derived from `status.signAPRAdvanceContract`)\nis passed to `confirmAdvanceLineDocuSignRequest` and `confirmDocuSignAchRequest`\nso the backend selects the APR or flat-fee contract template. It also affects\nwhether `AdvanceLineWithdrawFlow` routes through DocuSign at all:\n\n[source,typescript]\n----\nif (!status.instantAdvanceSigned || this.userService.isAprUserContract()) {\n    taskFlow.addTask(this.docusignAdvanceLineTask);   // must sign\n} else {\n    taskFlow.addTask(this.withdrawAdvanceLineTask)…   // withdraw inline\n}\n----\n\n== Cancel and decline\n\nFor advance flows, `event !== SingingComplete` means `initialize*Flow()` returns\n`of(false)` without clearing the `localStorage` flag or calling the API. For\ncontract flows the confirmation call is still made so the backend records the\ndecline, but no modal opens and no state changes beyond\n`userStatusUpdateRequested()`.\n\n== Adding a flow\n\n. Add the member to `DocusignFlowEnum`.\n. Add an `initialize<Name>Flow()` guard method following the pattern of the\n  closest existing flow (advance-style or contract-style).\n. Add a `handle<Name>()` method with the API confirmation and business action.\n. Add the `case` to `initializeFlow()`.\n. Build the return URL with `generateRedirectUrl()`, targeting a page covered by\n  `DocuSignGuard` (`dashboard` or `product-offerings`).\n. Always call `redirectToDocusign()` rather than setting `window.location`\n  directly, so the replay flag is set.\n"},{"component":"supplier-client","module":"ROOT","path":"business/advance-line.adoc","title":"Advance Line","description":"The revolving credit product — state, permissions, offer acceptance, withdrawal, expiry and rejection.","url":"https://docx.payability.com/supplier-client/business/advance-line.html","navOrder":23,"source":"= Advance Line\n:description: The revolving credit product — state, permissions, offer acceptance, withdrawal, expiry and rejection.\n\nThe Advance Line is a revolving facility. A supplier draws against a credit limit\nand repays automatically out of marketplace receipts.\n\n== State\n\nTwo slices, both registered by `AdvanceLineFeatureModule`.\n\n[cols=\"2,3\"]\n|===\n| `advanceLine` | Summary — the current facility. Actions `loadSummary`, `loadSummarySuccess`, `loadSummaryFailure`, `advanceLineWithdrawSuccess`.\n| `advanceLineOffers` | Draw offers and their terms. Actions `loadOffers`, `loadOffersSuccess`, `loadOffersFailure`, `clearOffers`, `withdrawOffer`.\n|===\n\nThe authoritative product state also lives on the user object as\n`status.advanceLine` (`AdvanceLineStatusDto`):\n\n[cols=\"2,3\"]\n|===\n| Field | Meaning\n\n| `currentAmount` | Credit currently available to draw.\n| `outstandingAmount` | Amount drawn and not yet repaid.\n| `expirationDate` | When the facility lapses.\n| `status` | `AdvanceLineStatusEnum` — `Active`, `Pending`, …\n| `uwStatus` | `AdvanceLineUWStatusEnum` — `Approved`, `Rejected`, `Pending`.\n| `type` | `AdvanceLineModeEnum` — `Legacy` or the modern Advance Line.\n|===\n\n== Legacy versus modern\n\n[IMPORTANT]\n====\n`calculateAdvanceLinePermissionsFromUser()` returns an empty array when there is\nno `advanceLine` **or** when `advanceLine.type === AdvanceLineModeEnum.Legacy`.\nA legacy supplier gets no Advance Line permissions at all — their product\nsurface is Instant Advance, and `INSTANT_ADVANCE_REPORT_WIDGET` is granted\nprecisely because their Advance Line is legacy.\n====\n\n== Permissions\n\n[cols=\"2,3\"]\n|===\n| Permission (dotted name) | Condition\n\n| `ADVANCE_LINE.VIEW_ADVANCE_LINE_WIDGET`\n| Active, **or** pending-and-expedited, **or** expired\n\n| `ADVANCE_LINE.ADVANCE_LINE_EXPIRED`\n| Expired **and** not active **and** not rejected\n\n| `ADVANCE_LINE.ADVANCE_LINE_REJECTED`\n| `uwStatus === Rejected` **or** (expired **and** active)\n\n| `ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION`\n| Supplier pending **and** `advanceLine.status === Pending` **and** `schema === Expedited`\n\n| `ADVANCE_LINE.WITHDRAW_ADVANCE_LINE`\n| `outstandingAmount > 0` **and** `supplierStatus !== Suspended`\n\n| `ADVANCE_LINE.VIEW_ADVANCE_LINE_REPORT`\n| `currentAmount > 0`\n|===\n\nExpiry is evaluated client-side: `new Date() > new Date(advanceLine.expirationDate)`.\n\n[NOTE]\n====\n`ADVANCE_LINE_REJECTED` is also granted when the line is *expired and still\nactive* — a state that means the facility lapsed without renewal, which the UI\npresents the same way as a rejection.\n====\n\n== Withdrawal\n\n`AdvanceLineWithdrawFlow` (`core/pyb/flows/advance-line/advance-line-withdraw.flow.ts`)\ncomposes the journey with the xref:architecture/flows-and-tasks.adoc[tasks engine].\n\n[source,typescript]\n----\ntaskFlow\n    .setContextData('data', this.prepareFlowContextData())\n    .addTask(this.openAdvanceLineInputModalTask,        this.defaultHandler)\n    .addTask(this.openAdvanceLineConfirmationModalTask, this.defaultHandler);\n\nif (!status.instantAdvanceSigned || this.userService.isAprUserContract()) {\n    taskFlow.addTask(this.docusignAdvanceLineTask);                    // <1>\n} else {\n    taskFlow\n        .addTask(this.withdrawAdvanceLineTask, this.defaultHandler)    // <2>\n        .addTask(this.updateUserStatusTask,    this.defaultHandler)\n        .addTask(this.openAdvanceLineSuccessModalTask);\n}\n----\n<1> Not yet signed, or on an APR contract → the supplier must sign. The flow ends\n    here because the browser leaves for DocuSign; it resumes on return.\n<2> Already signed and not APR → withdraw inline, refresh status, show the\n    success modal without leaving the app.\n\nFlow context is seeded with:\n\n[source,typescript]\n----\n{\n    advanceLine,                                 // the facility\n    id,                                          // supplier key\n    autodebitPmtCfg,                             // repayment configuration\n    docuSignNoteVisible: !instantAdvanceSigned,  // show the \"you'll be asked to sign\" note\n}\n----\n\n=== The DocuSign branch\n\n`DocusignFlowEnum.AdvanceLineActive`, handled on return by\n`DocusignVerificationService.handleAdvanceLineFlow()`:\n\n[listing]\n----\nclearDashboardNotifications()\nconfirmAdvanceLineDocuSignRequest(supplierKey, { event }, isAprUserContract())\ntrack(AdvanceLineEventTypes.DOCUSIGN_VERIFIED)\nadvanceLineService.withdraw(supplierKey, advanceLineKey, amount, offerType)\n  → fetchSupplierMarketplaces()\n  → loadBalance()\n  → loadSummary()\n  → advanceLineWithdrawSuccess()\n  → userStatusUpdateRequested()\n  → track(AdvanceLineEventTypes.FUNDS_WITHDRAWN, { offerType, amount })\n  → success modal: FEATURES.ADVANCE_LINE.MODALS.SUCCESS.HEADER / .BODY\n----\n\n== Offer acceptance\n\n`AdvanceLineAcceptOfferFlow` handles a supplier accepting a new offer, using:\n\n[cols=\"2,3\"]\n|===\n| `StartAdvanceLineAcceptOfferFlowTask` | Entry point.\n| `OpenAdvanceDetailsModalTask` | Present the terms.\n| `AcceptAdvanceLineTask` | Accept via the API.\n| `DirectAcceptAdvanceLineTask` | Accept without the intermediate confirmation.\n|===\n\nTracked with:\n\n[cols=\"2,3\"]\n|===\n| `AdvanceLineEventTypes.OFFER_ACCEPTED` | `AdvanceLineOfferAccepted`\n| `AdvanceLineEventTypes.OFFER_DECLINED` | `AdvanceLineOfferDeclined`\n| `AdvanceLineEventTypes.OFFER_SAVED_FOR_LATER` | `AdvanceLineOfferSavedForLater`\n|===\n\nIf a Long Term Contract offer is available at acceptance time, the LTC options\nmodal is inserted into the journey — see\nxref:business/long-term-contract.adoc[Long Term Contract].\n\n== Pending activation (expedited)\n\n`AdvanceLinePendingActivationFlow` covers an expedited supplier's first\ninteraction. The DocuSign flow is `DocusignFlowEnum.AdvanceLinePending`, which\nconfirms the *ACH agreement* rather than the advance contract, and the handler\nbranches on whether an amount was supplied:\n\n[cols=\"2,3\"]\n|===\n| Amount present | Confirm ACH, revoke `ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION`, then withdraw.\n| Amount absent | Confirm ACH, revoke the permission, dispatch `userStatusUpdateRequested()`.\n|===\n\nThe permission is revoked client-side immediately so the widget leaves its\n\"pending activation\" state before the status round-trip completes.\n\n== Legacy activation\n\n`AdvanceLineLegacyActivationFlow` handles activation for suppliers still on the\nlegacy advance product.\n\n== UI surface\n\n[cols=\"2,3\"]\n|===\n| Component | Role\n\n| `advance-line-widget` | Dashboard card — available credit, outstanding balance, draw CTA.\n| `advance-line-open-advances` | Open drawdowns.\n| `advance-line-past-advances` | Historical drawdowns.\n| `advance-rate-widget` | The supplier's advance rate.\n| `advance-line-description` | Formatted terms block.\n| `modals/advance-line/` | Input, confirmation and success dialogs.\n| `AdvanceLineTooltipTextService` | Builds the contextual tooltip copy shown on the widget.\n|===\n\nReporting lives at `/reports/instant-advance-report` and\n`/reports/outstanding-balance`; see xref:features/reports.adoc[Reports].\n\n== Analytics\n\n[cols=\"2,2,2\"]\n|===\n| Constant | Segment event | Properties\n\n| `OFFER_ACCEPTED` | `AdvanceLineOfferAccepted` | —\n| `OFFER_DECLINED` | `AdvanceLineOfferDeclined` | —\n| `OFFER_SAVED_FOR_LATER` | `AdvanceLineOfferSavedForLater` | —\n| `DOCUSIGN_VERIFIED` | `AdvanceLineDocusignVerified` | —\n| `FUNDS_WITHDRAWN` | `AdvanceLineFundsWithdrawn` | `{ offerType, amount }`\n| `ADVANCE_REPORT_LINK` | `ClickedOnAdvanceReportLink` | —\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"business/instant-advance.adoc","title":"Instant Advance","description":"The fixed-amount advance product, its mutual exclusion with the Advance Line, and repayment tracking.","url":"https://docx.payability.com/supplier-client/business/instant-advance.html","navOrder":24,"source":"= Instant Advance\n:description: The fixed-amount advance product, its mutual exclusion with the Advance Line, and repayment tracking.\n\nInstant Advance is a one-off cash advance against projected marketplace revenue,\ndisbursed as a lump sum and repaid from receipts. It predates the Advance Line\nand the two are mutually exclusive in the UI.\n\n== State\n\n[cols=\"2,3\"]\n|===\n| Slice | `instantAdvance` (`InstantAdvanceFeatureModule`)\n| Actions | `loadInstantAdvance`, `loadHttpInstantAdvance`, `loadCacheInstantAdvance`, `resetCacheInstantAdvance`, `loadInstantAdvanceSuccess`, `loadInstantAdvanceFailure`\n|===\n\nThe slice implements a read-through cache: `loadInstantAdvance` chooses between\n`loadCacheInstantAdvance` (serve the store copy) and `loadHttpInstantAdvance`\n(fetch). `resetCacheInstantAdvance` invalidates it after a mutation.\n\nOn the user object:\n\n[cols=\"2,3\"]\n|===\n| `status.activeInstantAdvance` | An `InstantAdvanceReport` for the current advance, or `null`.\n| `status.hasInstantAdvHistory` | The supplier has held an Instant Advance at some point.\n| `status.instantAdvanceSigned` | The Instant Advance contract is signed — this also governs whether an Advance Line draw needs DocuSign.\n|===\n\n== Mutual exclusion with the Advance Line\n\n[source,typescript]\n----\nconst hasValidActiveAdvance =\n    status.activeInstantAdvance &&\n    (!status.advanceLine || status.advanceLine?.type === AdvanceLineModeEnum.Legacy);\n\nconst hasValidPendingAdvance =\n    status.advanceLine?.type   === AdvanceLineModeEnum.Legacy &&\n    status.advanceLine?.status === AdvanceLineStatusEnum.Pending;\n----\n\n[IMPORTANT]\n====\nThe Instant Advance *widget* only renders when the supplier has no Advance Line\nat all, or a *legacy* one. A supplier migrated to the modern Advance Line never\nsees it, even with an active Instant Advance — the Advance Line widget is the\nsingle product surface in that case.\n====\n\n== Permissions\n\n[cols=\"2,3\"]\n|===\n| Permission | Condition\n\n| `REPORTS.INSTANT_ADVANCE_REPORT` + `REPORTS.INSTANT_ADVANCE_REPORT_MVP`\n| `status.hasInstantAdvHistory` (granted together)\n\n| `INSTANT_ADVANCE_REPORT_WIDGET`\n| `hasValidActiveAdvance`\n\n| `INSTANT_ADVANCE_PENDING`\n| `hasValidPendingAdvance` **and** `isUserPendingAndExpedited(user)`\n|===\n\n`REPORTS.VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT` exists in the permission tree\nfor element-level gating inside the MVP report but is not granted by\n`PermissionsService`.\n\n== Acceptance\n\n`DocusignFlowEnum.InstantAdvanceActive`. On return with `signing_complete`:\n\n[source,typescript]\n----\nthis.docusignService.confirmAdvanceLineDocuSignRequest(supplierKey, { event }).pipe(\n    map(() => {\n        this.permissionService.removePermission('INSTANT_ADVANCE_PENDING');\n        this.store.dispatch(userStatusUpdateRequested());\n        this.messageModalService.openSuccessModal({\n            title:       this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.TITLE'),\n            description: this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.CONTENT'),\n        });\n        return true;\n    }),\n);\n----\n\n[NOTE]\n====\nThis is the same `confirmAdvanceLineDocuSignRequest` endpoint the Advance Line\nuses, but called *without* the APR flag. The two products share the confirmation\nAPI; the flow type in the return URL is what distinguishes them.\n====\n\n`INSTANT_ADVANCE_PENDING` is passed as a bare name to `removePermission`, unlike\nthe Advance Line's dotted `ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION` — it is\na top-level node in `permissions.const.ts`, so that is correct.\n\n== Repayment\n\n[cols=\"2,3\"]\n|===\n| Slice | `instantAdvanceRepayment` (`InstantAdvanceRepaymentModule`)\n| Actions | `loadInstantAdvanceRepayments`, `loadInstantAdvanceRepaymentsSuccess`, `loadInstantAdvanceRepaymentsError`\n|===\n\nRepayment progress is rendered in `instant-advance-widget` and in the Instant\nAdvance report, using `ProgressBarCellRendererComponent` for per-row progress.\n\n== UI surface\n\n[cols=\"2,3\"]\n|===\n| Component | Role\n\n| `instant-advance-widget` | Dashboard card — active advance and repayment progress.\n| `modals/instant-advance-details-modal/` | Full terms — amount, fees, schedule.\n| `tasks/advance-line/open-instant-advance-details.task.ts` | Task wrapper for the details modal inside a flow.\n| `views/onboarding/product-offering/instant-advance/` | Offer presentation during onboarding.\n| `/reports/instant-advance-report` | `InstantAdvanceReportPageModule`.\n|===\n\nThe report model `InstantAdvanceReport` lives at\n`views/pages/reports/instant-advance-report-page-2/report/instant-advance-report.model.ts`\nand is also used by `Status.activeInstantAdvance` — a report model doubling as a\nstatus type.\n\n== Cross-sell\n\nSuppliers without an Instant Advance may see a cross-sell CTA on the Product\nOfferings page, tracked as:\n\n[source,typescript]\n----\nCrossSellEventType.ClickedInstantAdvanceCrossSellAppliedOnProductOfferings\n    = 'Instant Advance on Product Cross-Sell Page Applied';\n----\n\nThe `product_cross_sell_page` dashboard notification drives that page — see\nxref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `InstantAdvanceEventTypes.ADVANCE_REPORT_LINK` | `ClickedOnAdvanceReportLink`\n|===\n\n[NOTE]\n====\n`InstantAdvanceEventTypes.ADVANCE_REPORT_LINK` and\n`AdvanceLineEventTypes.ADVANCE_REPORT_LINK` resolve to the *same* Segment event\nname, `ClickedOnAdvanceReportLink`. Segment cannot distinguish which product the\nclick came from.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"business/long-term-contract.adoc","title":"Long Term Contract","description":"The LTC product — new contracts, renewals, contract changes, and the flows that drive them.","url":"https://docx.payability.com/supplier-client/business/long-term-contract.html","navOrder":25,"source":"= Long Term Contract\n:description: The LTC product — new contracts, renewals, contract changes, and the flows that drive them.\n\nA Long Term Contract (LTC) locks a supplier into a commitment period in exchange\nfor a discounted fee rate on their advance products.\n\n== Contract action types\n\n[source,typescript]\n----\n// src/app/core/pyb/constants/ltc-flow.constants.ts\nexport enum LtcContractActionType {\n    New    = 'new',\n    Renew  = 'renew',\n    Change = 'change',\n}\n----\n\nThe value travels through the flow context, into the DocuSign return URL, and\nback out into `LtcSummaryModalComponent`, so the summary can say whether the\nsupplier just signed, renewed or changed a contract.\n\n== State signals\n\n[cols=\"2,3\"]\n|===\n| `status.hasLTCHistory` | Ever signed an LTC. Grants `LCT_HISTORY`.\n| `status.hasActiveLTC` | An LTC is currently active. Selector `selectLtcActive`.\n| `status.renewLTCSigned` | A renewal has been signed. Selector `selectRenewLTCSigned`.\n| `status.LTCGenericContract` | The generic contract descriptor from the API.\n| Permission `LONG_TERM_CONTRACT` | Granted by default to every supplier.\n|===\n\n== Entry points\n\nLTC offers arrive as *dashboard notifications*, not as a product page:\n\n[cols=\"2,3\"]\n|===\n| `DashboardNotificationType.LongTermContract` (`new_long_term_contract`) | A new LTC offer → `LtcFlowService`.\n| `DashboardNotificationType.LongTermContractRenewal` (`renew_long_term_contract`) | A renewal offer → `LtcRenewalFlowService`.\n|===\n\nBoth carry `DashboardNotificationLtcDataDto[]` — the available options with their\ndurations, original fees and discounted fees.\n\n== New contract — `LtcFlowService`\n\n[source,typescript]\n----\nprivate buildFlow(optionsModalData: LtcOptionsModalData): TasksFlow {\n    return this.builder\n        .addTask(this.ltcOptionsTask,           this.ltcOptionsTaskHandler)\n        .addTask(this.ltcOptionConfirmationTask, this.ltcSelectedOptionTaskHandler)\n        .addTask(this.ltcDocuSignTask)\n        .setContextData('optionsModalData',   optionsModalData)\n        .setContextData('contractActionType', LtcContractActionType.New)\n        .getFlow();\n}\n----\n\nThree steps: choose an option, confirm it, sign it.\n\n[source,typescript]\n----\nprivate ltcOptionsTaskHandler = (flow: TasksFlow): FlowTaskDoneHandler<DashboardNotificationLtcDataDto> => {\n    return result => {\n        switch (result?.type) {\n            case FlowTaskResultType.Done:\n                flow.getContext().set('selectedOption', result.data);   // <1>\n                flow.runNext();\n                break;\n            case FlowTaskResultType.Cancel:\n                this.declineCta();                                      // <2>\n                break;\n        }\n    };\n};\n----\n<1> The chosen option is written into the shared flow context so the confirmation\n    and DocuSign tasks can read it.\n<2> Cancelling at the *first* step declines the notification outright, rather than\n    running backwards out of the flow.\n\nContext data is built from the first option's `originalFee`, with\n`LtcOptionPlatform.Amazon` as the platform.\n\n== Renewal — `LtcRenewalFlowService`\n\nRenewal is a two-stage journey. It first offers the supplier their *current*\ncontract, and only shows the full option list if they decline it.\n\n[listing]\n----\nrun(data)\n  → buildConfirmCurrentLtcFlow(data)\n        selectedOption   = data.find(c => c.isRealRenewal)\n        contractActionType = Renew\n        [ ltcOptionConfirmationTask ] → [ ltcDocuSignTask ]\n        registerOnDone(...)\n\n        Cancel ⇒ runChangeContractFlow(data)\n                     contractActionType = Change\n                     [ ltcOptionsTask ] → [ ltcOptionConfirmationTask ] → [ ltcDocuSignTask ]\n                     registerOnDone(...)\n\n                     Cancel ⇒ declineCta()\n----\n\n[source,typescript]\n----\nprivate getCurrentContractOption(contracts: DashboardNotificationLtcDataDto[]): DashboardNotificationLtcDataDto {\n    return contracts.find(contract => contract.isRealRenewal);\n}\n----\n\nThe `Change` flow prices its modal against `currentContract.discountedFeePercent`\nrather than `originalFee`, so a supplier changing contracts compares against what\nthey pay today, not the undiscounted rate.\n\n[NOTE]\n====\n`LtcFlowService` injects the shared root `TaskFlowBuilder`, while\n`LtcRenewalFlowService` constructs `new TaskFlowBuilder()` per flow. Both are\nvalid — `getFlow()` resets the shared builder — but the renewal service needs two\nindependent flows in one journey, hence the local instances.\n====\n\n== Signing\n\n`LtcDocuSignTask` starts `DocusignFlowEnum.LongTermContract`. On return:\n\n[source,typescript]\n----\nthis.docusignService.confirmLongTermContractDocuSignRequest(supplierKey, { event }).pipe(\n    map(() => {\n        if (event === DocusignEventTypeEnum.SingingComplete) {\n            this.store.dispatch(clearDashboardNotifications());\n            this.refreshData(data?.mode);\n            this.openLtcDocusignSummaryModal(data);\n        }\n        return true;\n    }),\n);\n----\n\n`LtcSummaryModalComponent` is opened with\n`{ duration, discountedFeePercent, contractActionType, mode }` and `maxWidth: 870`.\nIts close action decides where the supplier lands:\n\n[cols=\"2,3\"]\n|===\n| `LtcSummaryModalActionType.GoToReportsPage` | `/reports/contracts-overview-report`\n| `LtcSummaryModalActionType.GoToProductOfferingsPage` | `/product-offerings`\n| anything else | `/dashboard`\n|===\n\n== Modals\n\n[cols=\"2,3\"]\n|===\n| `ltc-options-modal` | The option list. Data: `LtcOptionsModalData` — `{ fee, options, platform, contractActionType }`.\n| `ltc-option-confirm-details-modal` | Confirm the selected option.\n| `ltc-sumarry-modal` | Post-signature summary. (Folder name is misspelled in the repo.)\n| `generic-ltc-modal` | Shared shell for LTC dialogs.\n|===\n\n== Reporting\n\nThe Contracts Overview report at `/reports/contracts-overview-report`\n(`ContractsOverviewPageModule`) lists LTC history — duration, discounted rate and\nstatus. `ContractsOverviewTextService` builds its descriptive copy.\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `LongTermContractEventType.OfferAccepted` | `LongTermContractOfferAccepted`\n| `LongTermContractEventType.OfferSavedForLater` | `LongTermContractOfferSaveForLater`\n| `LongTermContractEventType.OfferDeclined` | `LongTermContractOfferDeclined`\n| `LongTermContractEventType.DocusignVerified` | `LongTermContractDocusignVerified`\n|===\n\n[NOTE]\n====\n`OfferSavedForLater` maps to `LongTermContractOfferSaveForLater` — \"Save\", not\n\"Saved\". This differs from the Advance Line equivalent\n(`AdvanceLineOfferSavedForLater`). Both spellings are live in Segment.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"business/instant-transfer.adoc","title":"Instant Transfer","description":"Push-to-card payouts — eligibility, the TabaPay setup flow, fee calculation and error handling.","url":"https://docx.payability.com/supplier-client/business/instant-transfer.html","navOrder":26,"source":"= Instant Transfer\n:description: Push-to-card payouts — eligibility, the TabaPay setup flow, fee calculation and error handling.\n\nInstant Transfer moves a supplier's balance to a debit card immediately, outside\nthe normal payout schedule, for a fee. Card capture is handled by *TabaPay* in an\nembedded iframe, so raw card data never touches the application.\n\n== Eligibility\n\nTwo independent gates:\n\n[cols=\"2,3\"]\n|===\n| `user.ppmAvailable` | Grants the `INSTANT_TRANSFER_METHOD` permission — the feature is *offered*.\n| `user.canRequestInstantTransfer` | Server-side gate on actually *requesting* a transfer.\n|===\n\n[WARNING]\n====\nHolding `INSTANT_TRANSFER_METHOD` is not sufficient. A supplier can see the\nInstant Transfer surface while `canRequestInstantTransfer` is false, in which case\nthe request CTA is disabled.\n====\n\n== Setup flow\n\n`InstantTransferService.openInstantTransferSetupModal()`\n\n[source,typescript]\n----\nopenInstantTransferSetupModal(): Observable<any> {\n    this.twoFactorAuthenticationService.checkTwoFactorAuthenticationState().then(isTFAConfirmed => {\n        if (isTFAConfirmed) {                                       // <1>\n            this.dialogService.open<InstantTransferSetupModal>(InstantTransferSetupModal)\n                .beforeClosed()\n                .pipe(withLatestFrom(this.store.select(selectBusinessInformation)))\n                .subscribe(([res, bizInfo]) => this.nextStep(res, bizInfo));\n        } else {\n            this.onSetupFinish.error('twoFactorAuthenticationFail');  // <2>\n        }\n    });\n\n    return this.onSetupFinish.asObservable();\n}\n----\n<1> *Setting up a payout card requires a confirmed second factor.* This is the\n    only product flow in the application that gates on 2FA directly rather than\n    through a route guard.\n<2> A failed challenge errors the `onSetupFinish` subject, so callers can react.\n\n[listing]\n----\nsupplier clicks \"Set up Instant Transfer\"\n  → 2FA challenge (TwoFactorAuthenticationService)\n  → InstantTransferSetupModal opens with the TabaPay iframe\n  → supplier enters card details inside the iframe\n  → iframe posts a TabapayIframeEvent\n        success → updatePaymentConfigs(configs) → dispatch loadPaymentMethodsSuccess\n                → InstantTransferSetupSuccessModal\n                → optional makePaymentMethodAsDefault(paymentConfigKey)\n                → dispatch fetchDashboardNotifications()\n                → onSetupFinish.next('done')\n        error   → openFailureModal(errorData, businessInformation)\n----\n\n== The TabaPay iframe\n\n`views/partials/content/tabapay-iframe/` hosts the embedded form and translates\nits `postMessage` traffic into typed events:\n\n[cols=\"2,3\"]\n|===\n| `TabapayIframeEvent` | `{ type: TabapayIframeEventType, data }`\n| `TabapaySuccessData` | Carries the updated `PaymentMethodModel[]` in `configs`.\n| `TabapayErrorData` | Carries `ctx.code` (`TabapayErrorCode`) and `ctx.reason` (`TabapayErrorReason`).\n|===\n\nThe iframe origin comes from `environment.tabapayUrl`.\n\nOn success the service takes the returned configs and pushes them straight into\nthe store, bypassing a refetch:\n\n[source,typescript]\n----\nupdatePaymentConfigs(configs: PaymentMethodModel[]): void {\n    this.store.dispatch(loadPaymentMethodsSuccess({ paymentMethods: configs }));\n}\n----\n\n== Error handling\n\n`openFailureModal()` maps TabaPay's error taxonomy onto specific, actionable\ndialogs — each of which offers a \"Try again\" button that reopens the setup modal.\n\n[cols=\"2,2,3\"]\n|===\n| `TabapayErrorCode` | `TabapayErrorReason` | Modal\n\n| `CARD_ADDRESS_ERROR` | `WRONG_ZIP_CODE`, `ZIP_MISSMATCH`, `ADDRESS_MISSMATCH` | `configAddressErrorModal` — shows the business address on file\n| `CARD_ADDRESS_ERROR` | `WRONG_PHONE_COUNTRY` | `configPhoneCountryNumberErrorModal`\n| `CARD_ADDRESS_ERROR` | `WRONG_PHONE_NUMBER` | `configPhoneNumberErrorModal`\n| `CARD_ADDRESS_ERROR` | `NOT_SUPPORTED` | `configAvsCardNotSupportedErrorModal`\n| `CARD_NUMBER_ERROR` | — | `configCardNumberErrorModal`\n| `CARD_NOT_SUPPORTED` / `CARD_AVS_DECLINED` | `ZIP_MISSMATCH`, `ADDRESS_MISSMATCH` | `configAddressErrorModal`\n| `CARD_NOT_SUPPORTED` / `CARD_AVS_DECLINED` | `TEMPORARY_FAIL` | `configCommonErrorModal`\n| `CARD_NOT_SUPPORTED` / `CARD_AVS_DECLINED` | anything else | `configCardNotSupportedErrorModal`\n| anything else | — | `configCommonErrorModal`\n|===\n\nAll configurations are built in `instant-transfer.config.ts` from a shared\n`baseConfig` that supplies a Close button, a Try-again button wired to\n`openInstantTransferSetupModal`, and a danger-coloured exclamation icon. Every\nmessage interpolates `supportEmail` and `supportPhone` from\n`constants/contact.const.ts`.\n\n[NOTE]\n====\nThe address error modal interpolates the supplier's *business* address from the\n`businessInformation` slice, so the supplier can see exactly which address failed\nAVS without leaving the dialog.\n====\n\n== Fees\n\n[source,typescript]\n----\ngetFeesData(transferAmount: number): Observable<InstantTransferFeesData> {\n    return this.feesScheduleHelperService.getInstantTransferFees().pipe(\n        map(fees => this.feesScheduleHelperService.crateInstantTransferFeesData(transferAmount, fees)),\n    );\n}\n\nisFreeAccessEnabled(): Observable<boolean> {\n    return this.feesScheduleHelperService.hasFreeAccessPaymentFee();\n}\n----\n\n`FeesScheduleHelperService` reads the `fees` slice, which is populated once by\n`fetchFees()` from `GlobalSubscriptionsService.init()`.\n\n`hasFreeAccessPaymentFee()` also drives the success modal's `freeTrialAvailable`\nflag — suppliers without a free-access fee are told a free trial is available.\n\n== Default payout method\n\nThe success modal offers to make Instant Transfer the default:\n\n[source,typescript]\n----\nif (res.setAsDefaultMethod) {\n    this.setInstantTransferAsDefaultMethod(instantTransferMethod.paymentConfigKey);\n}\n----\n\nwhich dispatches `makePaymentMethodAsDefault({ paymentConfigKey })` against the\n`paymentMethods` slice. The relevant method is located by\n`configs.find(m => m.paymentType === PaymentMethodType.InstantTransfer)`.\n\n== UI surface\n\n[cols=\"2,3\"]\n|===\n| `instant-access-widget` | Dashboard CTA.\n| `modals/instant-transfer-setup/` | `InstantTransferSetupModal` and `InstantTransferSetupSuccessModal`.\n| `modals/instant-transfer-details-modal/` | Fee and limit details.\n| `modals/transfer-funds-modal/` | The transfer request itself.\n| `views/onboarding/product-offering/instant-access/` | Offer presentation during onboarding.\n|===\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `CrossSellEventType.ClickedInstantTransferSignUpOnProductOfferings` | `Clicked Instant Transfer Sign Up on Product Offerings`\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"business/seller-card.adoc","title":"Seller Card","description":"The prepaid business card — eligibility, the Marqeta-to-Airwallex transition, the unified card model, and every card operation.","url":"https://docx.payability.com/supplier-client/business/seller-card.html","navOrder":27,"source":"= Seller Card\n:description: The prepaid business card — eligibility, the Marqeta-to-Airwallex transition, the unified card model, and every card operation.\n\nThe Seller Card lets a supplier spend their Payability balance directly. Two\nissuers are live at once: the original **Marqeta** cards and the newer\n**Airwallex** cards, presented through a single unified UI.\n\n== Eligibility\n\n[source,typescript]\n----\nprivate isSellerCardAvailable(user: User): boolean {\n    const hasPaymentHistory = user?.status?.hasPaymentHistory;\n    const pcardAvailable    = user?.pcardAvailable;\n    const isFromUS          = this.isFromUS(user);\n    return hasPaymentHistory && (pcardAvailable || isFromUS) && !this.isUserDuringOnboarding(user);\n}\n\nprivate isFromUS(user: User): boolean {\n    const country            = user?._businessInformation?.address?.country;\n    const residentialCountry = user?._personalInformation?.residentialAddress?.country;\n    return [country, residentialCountry].every(code => code === CountryCode.US);\n}\n----\n\n[cols=\"2,3\"]\n|===\n| Condition | Note\n\n| `status.hasPaymentHistory` | At least one payment must have been processed.\n| `pcardAvailable` **or** US-based | Explicitly enabled by Payability, or *both* addresses are `US`.\n| Not onboarding | `dashboardStatus.status !== Onboarding`.\n|===\n\n[WARNING]\n====\n`isFromUS()` uses `.every()` across the business and residential addresses. A US\nbusiness with a non-US owner fails, and must be enabled through `pcardAvailable`.\n====\n\n== Two issuers, one UI\n\n[cols=\"2,2,2\"]\n|===\n| | Marqeta | Airwallex\n\n| Enum value | `CardProvider.Marqeta` | `CardProvider.Airwallex`\n| Modelled as | A payment method (`paymentMethods` slice) | A card (`airwallexCards` slice)\n| Status enum | `PCardStatus` | `AirwallexCardStatus`\n| Form factor | `PCardTypeIndicator` (from `instrument_type`) | `CardFormFactor`\n| Shipping | Physical cards shipped to a recipient address | Uses the cardholder address on file\n|===\n\n=== The unified card model\n\n`src/app/core/pyb/interfaces/unified-supplier-card.interface.ts` normalizes both\ninto one shape so the \"Your Cards\" panel can render them together.\n\n[source,typescript]\n----\nexport enum UnifiedCardStatus {\n    Active  = 'ACTIVE',\n    Blocked = 'BLOCKED',\n    Pending = 'PENDING',\n    Closed  = 'CLOSED',\n}\n\nexport interface UnifiedSupplierCard extends CardShipping {\n    id: string;\n    provider: CardProvider;\n    formFactorIndicator: string;\n    formFactorLabelKey: string;\n    last4: string | null;\n    status: UnifiedCardStatus;\n    statusLabelKey: string;\n    statusColor: string;\n    providerLabelKey: string;\n    createdAt?: string;\n    raw: PaymentMethodModel | AirwallexCard;   // <1>\n}\n----\n<1> `raw` retains the original provider model so the page can route an action to\n    the correct service.\n\nStatus mapping:\n\n[cols=\"2,2,2\"]\n|===\n| Unified | Marqeta (`PCardStatus`) | Airwallex (`AirwallexCardStatus`)\n\n| `Active` | `Active` | `ACTIVE`\n| `Blocked` | `Suspended` | `INACTIVE`\n| `Pending` | `Unactivated` | `PENDING`\n| `Closed` | `Terminated` | `CLOSED`\n|===\n\nUnmapped values fall through to `Closed`.\n\nPresentation mapping:\n\n[cols=\"2,2,2\"]\n|===\n| Unified status | Label key | Colour\n\n| `Active` | `SELLER_CARD.ACTIVE` | `success`\n| `Blocked` | `SELLER_CARD.BLOCKED` | `danger`\n| `Pending` | `SELLER_CARD.PENDING_ACTIVATION` | `warning`\n| `Closed` | `SELLER_CARD.TERMINATED` | `danger`\n|===\n\n=== Synthesized payment methods\n\nThe unified `/cards` endpoint is authoritative for display data, but Marqeta\n*actions* historically route through the payment-methods store. When a Marqeta\ncard in the `/cards` response has no matching payment method,\n`synthesizeMarqetaPaymentMethod()` fabricates a minimal stand-in carrying the\ncard token:\n\n[source,typescript]\n----\nconst synthesizeMarqetaPaymentMethod = (dto: UnifiedCardDto): PaymentMethodModel => ({\n    paymentConfig: {\n        pcard: {\n            card_token:           dto.token,\n            cardNumber:           dto.last_four ?? '',\n            pcard_status:         dto.state,\n            pcard_type_indicator: marqetaFormFactor(dto.instrument_type),\n        },\n    },\n} as unknown as PaymentMethodModel);\n----\n\n[NOTE]\n====\nA synthesized method is enough for block and activate, which only need the card\ntoken. The *View card* flow requires a real `paymentConfigKey`, so it is only\noffered when a genuine payment method was matched.\n====\n\nForm factor is derived from Marqeta's `instrument_type` string:\n\n[source,typescript]\n----\nconst marqetaFormFactor = (instrumentType: string | undefined): string =>\n    (instrumentType ?? '').toUpperCase().includes('VIRTUAL')\n        ? PCardTypeIndicator.Virtual\n        : PCardTypeIndicator.Physical;\n----\n\n== `AirwallexCardService`\n\n`src/app/core/pyb/services/airwallex-card.service.ts` — the HTTP surface, all\nsupplier-scoped.\n\n[cols=\"2,3\"]\n|===\n| Method | Endpoint\n\n| `getCards(supplierKey)` | `+GET /suppliers/{key}/cards+` — unified list, each entry tagged with `provider`.\n| `getCardProviders(supplierKey)` | `+GET /suppliers/{key}/card_providers+` — which issuers and form factors are offered.\n| `getConsentStatus(supplierKey)` | `+GET /suppliers/{key}/airwallex+` — `cardholder_terms_accepted`.\n| `saveConsent(supplierKey)` | `+POST /suppliers/{key}/airwallex/consent+`.\n| `newCard(supplierKey, request)` | `+POST /suppliers/{key}/cards/newcard+`.\n| `getPanToken(supplierKey, cardId)` | `+POST /suppliers/{key}/airwallex/pan-token+` — short-lived token for revealing the PAN.\n| `blockCard(supplierKey, cardId)` | `+POST /suppliers/{key}/cards/block/{cardId}+` with `{ provider: Airwallex }`.\n| `activateCard(supplierKey, cardId)` | `+POST /suppliers/{key}/cards/activate/{cardId}+` — also used to unblock.\n|===\n\nThe order request is shaped per provider:\n\n[source,typescript]\n----\nnewCard(supplierKey, { provider, formFactor, recipientAddress, expediteCard }) {\n    return this.http.post(`${this.env.apiUrl}/suppliers/${supplierKey}/cards/newcard`, {\n        provider,\n        form_factor: formFactor,\n        ...(recipientAddress ? { recipient_address: recipientAddress } : {}),   // <1>\n        ...(expediteCard != null ? { expedite_card: expediteCard } : {}),\n    });\n}\n----\n<1> `recipient_address` and `expedite_card` are only sent for issuers that ship a\n    physical card. Airwallex uses the cardholder address already on file.\n\n[IMPORTANT]\n====\nAirwallex consent must be recorded *before* a card is ordered. If `saveConsent()`\nfails, the order must not proceed.\n====\n\n== Card operations\n\nEach operation is a small service in\n`views/pages/seller-card/services/`, and each follows the same shape:\n*confirm → 2FA challenge → API call → analytics*.\n\n[source,typescript]\n----\nblock(card: AirwallexCard): Observable<AirwallexCardDto> {\n    return this.openBlockCardConfirmationModal().pipe(\n        filter(Boolean),\n        switchMap(() => this.check2FaState()),\n        filter(Boolean),\n        switchMap(() => this.airwallexCardService.blockCard(this.userService.userId, card.cardId)),\n        tap(() => this.eventLoggerService.track({ eventMessage: AirwallexCardEventTypes.CARD_BLOCKED })),\n    );\n}\n----\n\n[cols=\"2,3\"]\n|===\n| Service | Behaviour\n\n| `AirwallexCardBlockService` | Confirmation modal → 2FA → `blockCard()` → `CARD_BLOCKED`.\n| `AirwallexCardUnblockService` | Confirmation → 2FA → `activateCard()` → `CARD_UNBLOCKED`.\n| `AirwallexCardViewService` | 2FA → `AirwallexCardViewModal` (reveals card details via the PAN token).\n| `SellerCardBlockService` / `SellerCardUnblockService` | Marqeta equivalents.\n| `SellerCardViewService` | Marqeta card details.\n| `SellerCardActivateService` | Marqeta card activation.\n|===\n\n[IMPORTANT]\n====\n*Every* card operation requires a passed 2FA challenge. `filter(Boolean)` after\n`checkTwoFactorAuthenticationState()` silently ends the stream when the challenge\nfails, so no API call is made.\n====\n\n== Ordering a card — `/newcard`\n\n`NewCardModule` (`views/pages/newcard/`) is the ordering funnel. It is where\n`SellerCardListGuard` sends a supplier who has no cards yet.\n\n[cols=\"2,3\"]\n|===\n| `newcard.page.ts` | The ordering page.\n| `newcard-perks/` | Benefit presentation.\n| `newcard-wrapper/` | Layout shell.\n| `shipping-address.constants.ts` / `.interface.ts` | Physical-card delivery address form.\n| `newcard.constants.ts` | Page copy and option constants.\n|===\n\nBacked by the `cardOrder` slice: `loadCardProviders` resolves the available\n`CardProvider` / `CardFormFactor` combinations, then `orderCard` submits.\n\n[source,typescript]\n----\nexport enum CardProvider   { Marqeta = 'marqeta', Airwallex = 'airwallex' }\nexport enum CardFormFactor { Physical = 'PHYSICAL', Virtual = 'VIRTUAL' }\n----\n\n== The Airwallex transition\n\nExisting Marqeta cardholders must accept Airwallex's terms before their card is\nmigrated. This is delivered as a dashboard notification, not a page.\n\n[cols=\"2,3\"]\n|===\n| `airwallex_pcard_terms` | Accept the new provider's terms. Opens `AirwallexPcardTermsModalComponent`.\n| `identity_document_expired` | Identity document expired — opens `SellerCardTransitionModalComponent`, which folds the terms step in when needed.\n|===\n\n`GlobalSubscriptionsService` subscribes to both, and the *identity notification\ntakes precedence*:\n\n[source,typescript]\n----\nprivate subscribeToAirwallexPcardTermsNotification(): Subscription {\n    return this.store.pipe(\n        select(selectDashboardNotifications),\n        filter(notifications =>\n            this.canSeeAirwallexPcardTerms(notifications?.airwallexPcardTerms) &&\n            !this.canSeeIdentityDocumentExpired(notifications?.identityDocumentExpired)),   // <1>\n        take(1),\n    ).subscribe(() => this.displayAirwallexPcardTermsModal());\n}\n----\n<1> If the identity document has also expired, the standalone terms dialog is\n    suppressed — the transition modal handles both in one pass.\n\n=== Terms-only dialog\n\n[source,typescript]\n----\nprivate displayAirwallexPcardTermsModal() {\n    this.dialogService.open(AirwallexPcardTermsModalComponent)   // no disableClose → dismissible\n        .afterClosed()\n        .pipe(take(1))\n        .subscribe(result => {\n            if (result?.actionType === CtaActionTypesEnum.Accept) {\n                this.store.dispatch(acceptDashboardNotifications({\n                    payload: { type: DashboardNotificationType.AirwallexPcardTerms },\n                }));\n            }\n        });\n}\n----\n\nOnly *Accept* resolves the notification. Dismissing does nothing, so the dialog\nreturns on the next load until the terms are accepted. The dialog itself carries\nan amber \"Important\" callout about the cutover date and a mandatory checkbox\nlinking the Terms & Conditions and the Cardholder Agreement; the accept button is\ndisabled until it is ticked. Copy lives under `MODALS.AIRWALLEX_PCARD_TERMS` in\n`src/assets/i18n/en.ts`.\n\n=== Combined transition dialog\n\n[source,typescript]\n----\nprivate displayIdentityDocumentExpiredModal(notifications: DashboardNotifications): void {\n    const showTermsStep = !notifications?.identityDocumentExpired?.data?.airwallex_terms_accepted;   // <1>\n\n    this.dialogService.open(SellerCardTransitionModalComponent, { data: { showTermsStep } })\n        .afterClosed().pipe(take(1))\n        .subscribe(result => {\n            if (result?.actionType !== CtaActionTypesEnum.Accept) { return; }\n\n            if (result.termsAccepted) {\n                this.store.dispatch(acceptDashboardNotifications({\n                    payload: { type: DashboardNotificationType.AirwallexPcardTerms, silent: true },   // <2>\n                }));\n            }\n            this.store.dispatch(acceptDashboardNotifications({\n                payload: { type: DashboardNotificationType.IdentityDocumentExpired, silent: true },\n            }));\n\n            this.store.pipe(select(selectCurrentUser), take(1)).subscribe(user => {\n                if (user?.id) { this.entrustLauncher.launch(user.id).subscribe(); }   // <3>\n            });\n        });\n}\n----\n<1> The terms step is skipped when the notification payload already reports\n    `airwallex_terms_accepted`.\n<2> `silent: true` suppresses the per-notification success modal — the dialog has\n    already given the supplier a single confirmation.\n<3> Accepting launches Entrust identity re-verification. See\n    xref:integrations/entrust.adoc[Entrust Identity].\n\n== UI surface\n\n[cols=\"2,3\"]\n|===\n| `/seller-card` | `SellerCardPage` → `SellerCardListComponent`, guarded by `SellerCardListGuard`.\n| `/newcard` | The ordering funnel.\n| `/reports/card-transactions` | Card transaction history.\n| `widgets/seller-cards/` | Dashboard card summary.\n| `widgets/seller-cards-panel/` | The unified \"Your Cards\" panel.\n| `components/card-visual/`, `components/pcard/`, `components/awx-card/` | Card artwork per issuer.\n| `modals/seller-card-*` | Activate, order, block, unblock, view, transition.\n| `modals/airwallex-card-view-modal/` | Reveal Airwallex card details.\n| `modals/airwallex-pcard-terms-modal/` | Provider terms acceptance.\n|===\n\n== Decline codes\n\n`NetworkResponseCodeEnum` maps issuer decline codes to explanatory copy — expired\ncard, lost/stolen pick-up, insufficient funds, limit exceeded, CVV failure,\naccount blocked, suspected fraud, and around thirty more. See\nxref:reference/enums.adoc[Enum Reference].\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `AirwallexCardEventTypes.CARD_DETAILS_VIEWED` | `AirwallexCardDetailsViewed`\n| `AirwallexCardEventTypes.CARD_BLOCKED` | `AirwallexCardBlocked`\n| `AirwallexCardEventTypes.CARD_UNBLOCKED` | `AirwallexCardUnblocked`\n| `AirwallexCardEventTypes.TERMS_ACCEPTED` | `AirwallexTermsAccepted`\n| `CardOrderEventTypes.ORDER_STARTED` | `CardOrderStarted`\n| `CardOrderEventTypes.CARD_ORDERED` | `CardOrdered`\n| `CrossSellEventType.ClickedSellerCardSignUpOnProductOfferings` | `Clicked Seller Card Sign Up on Product Offerings`\n|===\n\n[NOTE]\n====\nThe application never handles a raw PAN or PIN. Card details are revealed through\na short-lived provider token (`getPanToken()` for Airwallex) rendered inside the\nprovider's own secure surface.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"business/vendor-pay.adoc","title":"Vendor Pay","description":"Forward payments — sending money to vendors, and the incoming/outgoing payment reports.","url":"https://docx.payability.com/supplier-client/business/vendor-pay.html","navOrder":28,"source":"= Vendor Pay\n:description: Forward payments — sending money to vendors, and the incoming/outgoing payment reports.\n\nVendor Pay (\"forward payments\") lets a supplier send part of their Payability\nbalance directly to a vendor, and lets a vendor see payments arriving from\nsuppliers.\n\n== Availability\n\n[cols=\"2,3\"]\n|===\n| Permission | `VENDOR_PAY`\n| Condition | `status.hasForwardPaymentEnabled`\n| Menu | `MenuConfig` entry `MENU.REPORTS.VENDOR_PAY` → `URLS_ROUTER.REPORTS.VENDOR_PAY_INCOMING`\n|===\n\n== Sending money — `/send-money`\n\n`SendMoneyModule` (`views/pages/send-money/`). A single reactive form collecting\nthe recipient and amount.\n\n[cols=\"2,3\"]\n|===\n| Component | `SendMoneyComponent` — extends `Subscriber`, `OnPush`.\n| Service | `SendMoneyPageService`, wrapping `SendMoneyService` from `@payability/pyb-ng2-api`.\n| Balance | Read from `selectBalanceState` so the supplier sees what is available.\n|===\n\n=== Form rules\n\n[source,typescript]\n----\nNAME_VALIDATORS = Validators.compose([\n    Validators.required,\n    Validators.minLength(3),\n    Validators.maxLength(22),\n    Validators.pattern('[A-Z]{1,1}[a-z]+'),   // <1>\n]);\n----\n<1> Recipient names must be a single capitalised word of 3–22 characters. This\n    rejects hyphenated names, names with apostrophes, and multi-part names —\n    worth knowing when triaging \"my recipient's name won't validate\" reports.\n\nPhone entry uses `ngx-intl-tel-input` with `CountryISO.UnitedStates` selected by\ndefault and the United States and United Kingdom as preferred countries. Country\nsearch matches on ISO2 code or name.\n\nNotification method is chosen from a list keyed on\n`SEND_MONEY.INPUTS.LABELS.NOTIFICATION_METHODS.*` — the recipient is notified by\nemail or SMS.\n\n=== Submission\n\n[source,typescript]\n----\nsendRequest(supplierKey: string, payload: any): any {\n    this.sendMoneyService.requestSendMoney(supplierKey, { ...payload, supplier_key: supplierKey }).subscribe({\n        next: () => {\n            this.loading$.next(false);\n            this.toastService.show('Successfully requested vendor pay!', PybColorPalette.Success, 5000, true, false);\n            this.dialog.open(PybMessageModalComponent, { data: { title: 'Your payment has been sent!', … } });\n        },\n        // …\n    });\n}\n----\n\n[WARNING]\n====\nThe success toast and the confirmation modal text in `SendMoneyPageService` are\nhardcoded English strings, not translation keys. This is an outlier — the rest of\nthe application routes all copy through `TranslateService`. See\nxref:development/i18n.adoc[Internationalization].\n====\n\n`VendorPaySuccessModal` exists in `views/partials/modals/vendor-pay-success-modal/`\nbut the current implementation opens `PybMessageModalComponent` instead; the\noriginal call is commented out in the service.\n\n== Incoming payments — `/vendor-pay`\n\n`VendorPayIncomingReportsModule` (`views/pages/vendorpay-incoming-reports/`).\nShows payments arriving *to* this account from other suppliers.\n\n[cols=\"2,3\"]\n|===\n| `vendorpay-reports.component.ts` | The AG Grid page.\n| `vendorpay-reports.service.ts` | Data access.\n| `vendorpay-reports-mapping.service.ts` | DTO → row-model mapping.\n| `grid-config.ts` | Column definitions.\n| `vendorpay-reports.types.ts` | `IVendorPayReportDto`, `IVendorPayStatus`.\n|===\n\n[source,typescript]\n----\nexport interface IVendorPayReportDto {\n    total: number;\n    forwardPayments: IVendorPayStatus[];\n}\n\nexport interface IVendorPayStatus {\n    forwardPaymentKey: string;\n    type: string;\n    supplierKey?: string;\n    xactKey: string;\n    senderSupplierKey: string;\n    recipientEmailAddress: string;\n    fromTo: string;\n    amount: number;\n    dateRequested: string;\n    forwardPaymentStatus: string;\n}\n----\n\n`fromTo` is a display field that resolves to the counterparty — the sender on an\nincoming row, the recipient on an outgoing one — which is how one row model\nserves both reports.\n\n== Outgoing payments — `/reports/vendor-pay-report`\n\n`VendorpayReportsModule` (`views/pages/reports/vendorpay-reports/`) — the mirror\nreport inside the reports shell, showing payments sent *from* this account.\n\nAdditional components:\n\n[cols=\"2,3\"]\n|===\n| `pyb-vendor-payment-filter` | Filter bar for status and date range.\n| `pyb-vendorpay-status-bag` | Renders `forwardPaymentStatus` as a coloured chip.\n|===\n\nColumn configuration is provided through DI tokens declared by `ReportsModule`:\n\n[cols=\"2,3\"]\n|===\n| `VENDOR_PAY_INCOMING_REPORT_COLUMNS_CONFIG_TOKEN` | `vendorPayIncomingColConfig`\n| `VENDOR_PAY_OUTGOING_REPORT_COLUMNS_CONFIG_TOKEN` | `vendorPayOutgoingColConfig`\n| `VENDOR_PAY_REPORT_AUTO_COLUMNS_CONFIG_TOKEN` | `vendorPayReportAutoColumnGroupConfig`\n|===\n\nThe two reports share a component and differ only by injected column set.\n\n== Dashboard widget\n\n`widgets/vendorpay-incoming-payments-widget/` summarises incoming payments on the\ndashboard, gated on `VENDOR_PAY`.\n\n== Related\n\n* xref:features/reports.adoc[Reports]\n* xref:security/permissions.adoc[Permissions System]\n"},{"component":"supplier-client","module":"ROOT","path":"business/marketplaces.adoc","title":"Marketplaces & Compliance","description":"Supported sales channels, their fixed identifiers, the compliance key system, and connection health.","url":"https://docx.payability.com/supplier-client/business/marketplaces.html","navOrder":29,"source":"= Marketplaces & Compliance\n:description: Supported sales channels, their fixed identifiers, the compliance key system, and connection health.\n\nPayability advances against marketplace receivables, so a supplier's connected\nsales channels are core domain data — not a settings detail.\n\n== Supported marketplaces\n\nIdentities are fixed UUIDs, shared with the backend.\n\n[source,typescript]\n----\n// src/app/core/pyb/enums/marketplaces.enum.ts\nexport enum MarketplaceEnum {\n    Amazon            = '37036286-6ab8-427e-8a5f-ce53cac22f3e',\n    Walmart           = 'c7d2ca4e-d44b-4713-b804-abc58d38f186',\n    TopHatter         = 'cae8ea7b-971b-4595-b920-50d8975f849e',\n    Payoneer          = '3fd6bd61-6d83-4103-84e8-deb3c02b51cb',\n    Volusion          = '5609080f-f80c-421a-ab60-e1cc32e32a20',\n    Stripe            = '57387ee6-087c-4d04-b8bc-247d58355ca2',\n    Paypal            = 'f5cd98e5-ee28-456e-97b9-c32b9f01d509',\n    Ebay              = '44ce7193-05ed-4ff4-b806-1b4315dbe944',\n    Authorize         = 'cc5da097-0366-483d-96f5-f58764c3f89d',\n    Shopify           = '60e9092e-66d5-4e53-b784-df0a4d552582',\n    Newegg            = '39ec5fa0-e6a4-473a-b185-fbfb83c29901',\n    NeweggVendors     = 'df3ba48c-8dcd-4739-8dd2-91bb480d2826',\n    Etsy              = 'd3297290-0447-4d6c-8679-24dedb5c4e36',\n    PayabilityPayment = '1333cf23-daad-462b-a931-6632b5e63c7b',\n}\n----\n\n[WARNING]\n====\nThese UUIDs are contract values. Changing one silently disconnects every supplier\non that channel.\n====\n\nNewegg is gated behind the `NEW_EGG_PROVIDER` permission, which is only granted\noutside production — see xref:security/permissions.adoc[Permissions System].\n\n== Compliance keys\n\nA *compliance* is one requirement a supplier must satisfy, identified by a UUID.\nThey serve two purposes: onboarding progress, and per-marketplace connection\nrequirements.\n\n=== Onboarding compliances\n\n`PayabilityComplianceEnum`:\n\n[cols=\"2,3\"]\n|===\n| `LinkMarketplace` | Connect at least one channel.\n| `PersonalInformation` / `PersonalDetails` | Contact and personal detail collection.\n| `BusinessInformation` | Legal entity details.\n| `KycIdentity` | Identity verification.\n| `BankStatement` | Bank statement upload.\n| `TransferInformation` | Payout configuration.\n| `TwoStepVerification` | 2FA enrolment.\n| `OfferSummary` | Review the product offer.\n| `SignAnAgreement` | Sign the supplier agreement.\n| `SalesCallSummary` | Sales call completed.\n| `Other` | Catch-all.\n|===\n\n`DoctypeComplianceEnum.Documentation` covers generic document uploads.\n\n=== Per-marketplace compliances\n\nEach integration has its own key set, which is how the UI knows *which* step of a\nconnection is outstanding.\n\n[cols=\"2,3\"]\n|===\n| Enum | Keys\n\n| `AmazonComplianceKeys` | `API`, `Invite`, `Permissions`\n| `WalmartComplianceKeys` | `GatewayAccess`, `ComplianceApi`, `QuickOnboarding`, `Access`, `AutoconnectAgreement`\n| `ShopifyComplianceKeys` | `Invite`\n| `EbayComplianceKeys` | `Access`\n| `EtsyComplianceKeys` | `Rate`, `Manual`\n| `NeweggComplianceKeys` | `Invite`\n| `NeweggVendorsComplianceKeys` | `Invite`\n| `NeweggComplianceActionKeys` | `ManualInvite`\n| `PayoneerComplianceKeys` | `Access`\n| `AuthorizeComplianceKeys` | `Invite`\n| `PaymentGatewaysKeys` | `Stripe`, `PayPal`, `AuthorizeNet`\n|===\n\n`WalmartComplianceTypes` is a *reverse* map — UUID to human label\n(`compliance_api`, `quick-onboarding`, `gateway-access`, `access`,\n`autoconnect_agreement`) — used to render Walmart's step names.\n\n[NOTE]\n====\n`PayoneerComplianceKeys.Access` and `PaymentGatewaysKeys.PayPal` are the same\nUUID (`1b23b149-8ea1-4937-a56c-628c296f5c2b`), as are\n`AuthorizeComplianceKeys.Invite` and `PaymentGatewaysKeys.AuthorizeNet`. The\nduplication is intentional — the same compliance record is reached from the\nmarketplace flow and from the payment-gateway flow.\n====\n\n== Permission states\n\nA supplier's authorization on a channel is tracked with `MpPermissionEnum`:\n\n[cols=\"2,3\"]\n|===\n| `approved` / `perm-approved` | Access granted.\n| `verified` | Access verified end-to-end.\n| `pending` / `perm-pending` | Awaiting the marketplace.\n| `rejected` / `perm-rejected` | Refused.\n| `incomplete` | Started but not finished.\n| `disabled` | Turned off.\n| `sales resolution` / `perm-sales resolution` | Escalated to the sales team.\n|===\n\n`AccessStatusEnum` (`pending`, `incomplete`, `rejected`, `completed`, `verified`)\nand `ApiStatus` (`needed`, `required`, `rejected`, `confirmed`, `incomplete`,\n`in-review`, `pending`) express the same idea at different granularities — the\ncompliance record and the API connection respectively.\n\n== State\n\n[cols=\"2,3\"]\n|===\n| `marketplaces` entity | The catalog of supported marketplaces. Persisted to `localStorage` under `__marketplaces_storage__`.\n| `supplierMarketplaces` entity | This supplier's connections. Refetched on init and after an Advance Line withdrawal.\n|===\n\nBoth are keyed by `marketplaceKey`. See xref:state/entity-slices.adoc[Entity Slices].\n\n== Connection health\n\n`ApiConnectorsService` monitors Amazon and Shopify connections and drives\nreconnect banners and modals. See\nxref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\n`status.ascApiStatus` carries the Amazon Seller Central API status, and\n`user.amazonApiSwitching` indicates the supplier is mid-migration between Amazon\nconnection methods.\n\n== Amazon-specific flows\n\nDispatched through the `marketplaces` entity slice:\n\n[cols=\"2,3\"]\n|===\n| `openAmazonInviteWrapper` | Open the invite-Payability wizard.\n| `openAmazonPermissionWrapper` | Open the grant-permissions wizard.\n| `amazonSuspendedProcessBreak` | The supplier abandoned the Amazon-suspended flow.\n| `amazonSuspendedProcessCompleted` | The Amazon-suspended flow finished.\n|===\n\n`modals/amazon-suspended/` handles the case where the supplier's Amazon account\nitself is suspended — distinct from a Payability suspension.\n\n== UI surface\n\n`/my-marketplaces` (`MyMarketplacesPageModule`):\n\n[cols=\"2,3\"]\n|===\n| `my-marketplaces-page/` | The page shell.\n| `marketplace-widget/` | One connected channel with status and actions.\n| `marketplace-content/` | Connection detail panel.\n| `add-marketplaces/` | Add-a-channel flow.\n| `unsuported-marketplaces/` | Report a channel Payability does not integrate with.\n| `marketplace-placeholder/`, `marketplace-single-placeholder/` | Loading skeletons.\n|===\n\n== Analytics\n\nTwo event families cover the marketplace journey — `MarketplaceEventType` for\nlifecycle changes, and `LinkMarketplaceEventType` / `InviteMarketplaceEventType`\nfor the onboarding wizards. `MarketplaceEventsService` and\n`AmazonEventTrackerService` emit them. Full list:\nxref:analytics/event-catalog.adoc[Event Catalog].\n"},{"component":"supplier-client","module":"ROOT","path":"business/dashboard-notifications.adoc","title":"Dashboard Notifications & CTAs","description":"The server-driven notification system that powers dashboard CTA cards and auto-opened modals.","url":"https://docx.payability.com/supplier-client/business/dashboard-notifications.html","navOrder":30,"source":"= Dashboard Notifications & CTAs\n:description: The server-driven notification system that powers dashboard CTA cards and auto-opened modals.\n\nDashboard notifications are the primary mechanism for surfacing offers, required\nactions and announcements. The server decides *what* to show; the client decides\n*how* — a CTA card on the dashboard, or a modal opened automatically at session\nstart.\n\n== Notification types\n\n[source,typescript]\n----\nexport enum DashboardNotificationType {\n    InstantAdvance          = 'instant_advance',\n    InstantTransfer         = 'instant_transfer',\n    OpenInvoices            = 'open_invoices',\n    SellerCard              = 'seller_card',\n    PlaidCollection         = 'collection_account',\n    PlaidAdvance            = 'advance_underwriting',\n    PreAdvanceLine          = 'pre_advance_line',\n    AdvanceLine             = 'advance_line',\n    LongTermContract        = 'new_long_term_contract',\n    LongTermContractRenewal = 'renew_long_term_contract',\n    WelcomeMessage          = 'new_sd_welcome',\n    SupplierSuspension      = 'supplier_suspension',\n    SellerCardAd            = 'seller_card_transfer_ad',\n    PreInstantAdvance       = 'pre_instant_advance',\n    CancellationSurvey      = 'cancellation_survey',\n    CrossSell               = 'product_cross_sell_page',\n    AirwallexPcardTerms     = 'airwallex_pcard_terms',\n    IdentityDocumentExpired = 'identity_document_expired',\n}\n----\n\n== The `Notification` model\n\n`src/app/core/auth/_models/notifications.model.ts`\n\n[source,typescript]\n----\nexport class Notification<D = any> {\n    notificationSupplierKey: string;\n    supplierKey: string;\n    notificationType: DashboardNotificationType;\n    externalProductId?: any;\n    startDate: string;\n    expirationDate?: any;\n    hidden: boolean;\n    declined: boolean;\n    declineLoading: boolean;\n    acceptLoading: boolean;\n    visited: boolean;\n    seen: boolean;\n    inactivated: boolean;\n    data: D;                  // <1>\n    successShow: boolean;\n    firstSeen: boolean;\n    actionLoading: boolean;\n    additional_data?: any;\n}\n----\n<1> Typed per notification. LTC notifications carry\n    `DashboardNotificationLtcDataDto[]`, the identity notification carries\n    `IdentityDocumentExpiredDataDto`, the suspension notification carries\n    `SuspensionNotificationDataDto`.\n\n`DashboardNotifications.fromDto()` maps the flat API payload onto named fields:\n\n[cols=\"2,2\"]\n|===\n| API key | Model field\n\n| `collection_account` | `plaidCollection`\n| `advance_underwriting` | `advanceUnderwriting`\n| `instant_transfer` | `instantTransfer`\n| `open_invoices` | `openInvoices`\n| `seller_card` | `sellerCard`\n| `instant_advance` | `instantAdvance`\n| `pre_advance_line` | `preAdvanceLine`\n| `advance_line` | `advanceLine`\n| `new_long_term_contract` | `longTermContract`\n| `renew_long_term_contract` | `longTermContractRenewal`\n| `new_sd_welcome` | `welcomeMessage`\n| `item_login_required` | `itemLoginRequired`\n| `pre_instant_advance` | `preInstantAdvance`\n| `supplier_suspension` | `supplierSuspension`\n| `seller_card_transfer_ad` | `sellerCardAd`\n| `cancellation_survey` | `cancellationSurvey`\n| `product_cross_sell_page` | `crossSell`\n| `airwallex_pcard_terms` | `airwallexPcardTerms`\n| `identity_document_expired` | `identityDocumentExpired`\n|===\n\n[NOTE]\n====\n`airwallex_pcard_terms` and `identity_document_expired` are read through\n`(dto as any)` because `DashboardNotificationsDto` in `@payability/pyb-ng2-api`\ndoes not expose them yet. Both are marked with a `TODO` to remove the cast once\nthe API package catches up.\n====\n\n`itemLoginRequired` is mapped from the DTO but has no corresponding\n`DashboardNotificationType` member — it is currently read-only state.\n\n== Actions on a notification\n\n[source,typescript]\n----\nexport enum DashboardNotificationAction {\n    Visited        = 'visited',\n    Hide           = 'hide',\n    Show           = 'show',\n    Decline        = 'decline',\n    DeclineLoading = 'declineLoading',\n    Accept         = 'accept',\n    AcceptLoading  = 'acceptLoading',\n    ActionLoading  = 'actionLoading',\n}\n----\n\nEvery state-changing action follows the same effect shape: call\n`notificationService.callNotificationAction(supplierKey, type, action)`, then\ndispatch `updatedDashboardNotifications({ type, action, value })` to reflect it\nlocally.\n\n[source,typescript]\n----\nvisitedNotification$ = createEffect(() =>\n    this.actions$.pipe(\n        ofType(DashNotificationActions.visitedDashboardNotifications),\n        withLatestFrom(this.store.select(selectCurrentUserId)),\n        switchMap(([{ payload }, supplierKey]) => {\n            const type = (payload as ActionsDashboardNotificationPayload).type;\n            return zip(of(type),\n                this.notificationService.callNotificationAction(supplierKey, type, DashboardNotificationAction.Visited));\n        }),\n        map(([type]) => DashNotificationActions.updatedDashboardNotifications({\n            payload: { type, action: DashboardNotificationAction.Visited, value: true },\n        })),\n    ),\n);\n----\n\n=== Accept\n\n`acceptDashboardNotificationsSuccess` opens a success modal whose copy is chosen\nby notification type:\n\n[source,typescript]\n----\nmap(({ payload: { type, silent } }) => {\n    if (!silent) {\n        const phraseKeyByType: Partial<Record<DashboardNotificationType, string>> = {\n            [DashboardNotificationType.PreAdvanceLine]:      'CTA.PRE_ADVANCE_LINE.SUCCESS',\n            [DashboardNotificationType.AirwallexPcardTerms]: 'CTA.AIRWALLEX_PCARD_TERMS.SUCCESS',\n        };\n        const phraseKey = phraseKeyByType[type] || 'CTA.PRE_INSTANT_ADVANCE.SUCCESS';   // <1>\n\n        this.translateService.get(phraseKey).subscribe(({ HEADER: title, DESCRIPTION: description }) => {\n            this.messageModalService.openSuccessModal({ title, description });\n        });\n    }\n    return DashNotificationActions.fetchDashboardNotifications();   // <2>\n}),\n----\n<1> Any type not in the map falls back to the *Pre Instant Advance* copy. Adding\n    a notification that is accepted without adding a mapping produces a\n    plausible-looking but wrong success message.\n<2> The whole set is refetched after every accept, so server-side follow-on\n    changes are picked up.\n\n`silent: true` suppresses the modal entirely — used when a single dialog resolves\nseveral notifications at once, as in the Seller Card transition.\n\n== CTA cards\n\n`views/partials/content/cta/` renders notifications as dismissible dashboard\ncards. `CtaFactory` maps a notification type onto a strategy that produces an\n`IAdCard` configuration.\n\n[source,typescript]\n----\npublic getCtaConfig(type: DashboardNotificationType, notification: Notification): IAdCard {\n    switch (type) {\n        case DashboardNotificationType.PreAdvanceLine:    return new CtaPreAdvanceLineStrategy(…).generate();\n        case DashboardNotificationType.AdvanceLine:       return new CtaAdvanceLineStrategy(…).generate();\n        case DashboardNotificationType.LongTermContract:  return new CtaLongTermContractStrategy(…).generate();\n        case DashboardNotificationType.InstantAdvance:    return new CtaInstantAdvanceStrategy(…).generate();\n        case DashboardNotificationType.PreInstantAdvance: return new CtaPreInstantAdvanceStrategy(…).generate();\n        case DashboardNotificationType.CrossSell:         return new CtaCrossSellStrategy(…).generate();\n        default: return null;\n    }\n}\n----\n\n=== Active strategies\n\n[cols=\"2,3\"]\n|===\n| `CtaPreAdvanceLineStrategy` | Pre-qualified Advance Line offer.\n| `CtaAdvanceLineStrategy` | Live Advance Line offer.\n| `CtaLongTermContractStrategy` | New LTC offer → `LtcFlowService`.\n| `CtaInstantAdvanceStrategy` | Instant Advance offer.\n| `CtaPreInstantAdvanceStrategy` | Pre-qualified Instant Advance offer.\n| `CtaCrossSellStrategy` | Product cross-sell page.\n|===\n\n=== Disabled strategies\n\nThese exist in the repository but are commented out of the factory:\n\n[cols=\"2,3\"]\n|===\n| `CtaSellerCardStrategy` | Seller Card offer.\n| `CtaInstantTransferStrategy` | Instant Transfer offer.\n| `CtaOpenInvoicesStrategy` | Open invoices.\n| `CtaLongTermContractRenewalStrategy` | LTC renewal — disabled deliberately per CA-513.\n|===\n\n[WARNING]\n====\n`CtaFactory.getCtaConfig` has a duplicate `case DashboardNotificationType.AdvanceLine`\n— the second is unreachable. Harmless, but do not copy the pattern.\n====\n\nSupporting pieces:\n\n[cols=\"2,3\"]\n|===\n| `cta-strategy.ts` | The abstract strategy base.\n| `cta-long-term-contract-abstract.strategy.ts` | Shared LTC behaviour for the new and renewal strategies.\n| `cta-session-keys.ts` | Session-storage keys for per-session dismissal.\n| `open-invoices/` | A dedicated `OpenInvoicesCtaComponent` outside the strategy system.\n|===\n\nThe user's response is expressed with `CtaActionTypesEnum`:\n\n[source,typescript]\n----\nexport enum CtaActionTypesEnum {\n    Accept       = 'accept',\n    Decline      = 'decline',\n    SaveForLater = 'save',\n}\n----\n\n== Auto-opened modals\n\nThree notifications bypass the card system and open a modal directly from\n`GlobalSubscriptionsService.init()`. Each subscribes with `take(1)`, so it fires\nat most once per session.\n\n[cols=\"2,3\"]\n|===\n| `new_sd_welcome` | `WelcomeModalComponent`. On close, dispatches `declineDashboardNotifications` — seen once, never again.\n| `airwallex_pcard_terms` | `AirwallexPcardTermsModalComponent`. Only *Accept* resolves it; dismissing leaves it to reappear.\n| `identity_document_expired` | `SellerCardTransitionModalComponent`, which folds in the terms step when needed and launches Entrust on accept.\n|===\n\nThe identity notification *suppresses* the standalone terms dialog:\n\n[source,typescript]\n----\nfilter(notifications =>\n    this.canSeeAirwallexPcardTerms(notifications?.airwallexPcardTerms) &&\n    !this.canSeeIdentityDocumentExpired(notifications?.identityDocumentExpired)),\n----\n\n\"Can see\" means the notification exists and `declined` is false.\n\nSee xref:business/seller-card.adoc[Seller Card] for the full transition flow.\n\n== Notifications that drive flows\n\n[cols=\"2,3\"]\n|===\n| `new_long_term_contract` | `LtcFlowService` — see xref:business/long-term-contract.adoc[Long Term Contract].\n| `renew_long_term_contract` | `LtcRenewalFlowService`.\n| `cancellation_survey` | The cancellation survey — see xref:business/cancellation.adoc[Cancellation & Reactivation].\n| `supplier_suspension` | Suspension messaging — see xref:business/suspension.adoc[Suspension].\n| `collection_account` / `advance_underwriting` | Plaid linking prompts — see xref:integrations/plaid.adoc[Plaid].\n| `open_invoices` | `enableOpenInvoices` → `OpenInvoicesService.enableOpenInvoices()` then a `CTA.OPEN_INVOICES.SUCCESS` modal.\n|===\n\n== Permission gating\n\nThe notification feed on the dashboard requires `VIEW_NOTIFICATIONS`, which is\ndenied to pending-and-expedited suppliers. The auto-opened modals in\n`GlobalSubscriptionsService` are *not* permission-gated — they run for anyone\nwhose notification set contains them.\n\n== Clearing\n\n`clearDashboardNotifications()` empties the slice locally without an API call. It\nis dispatched at the start of most DocuSign handlers so stale offers disappear\nimmediately, before `fetchDashboardNotifications()` brings back the refreshed set.\n"},{"component":"supplier-client","module":"ROOT","path":"business/cancellation.adoc","title":"Cancellation & Reactivation","description":"Deactivating an account, the cancellation survey, the marketplace opt-out banners, and reactivation.","url":"https://docx.payability.com/supplier-client/business/cancellation.html","navOrder":31,"source":"= Cancellation & Reactivation\n:description: Deactivating an account, the cancellation survey, the marketplace opt-out banners, and reactivation.\n\nA supplier can request deactivation from account settings. Because Payability\nadvances against future receipts, cancellation is not immediate — each connected\nmarketplace has an *opt-out date* after which funding stops, and the supplier may\ncarry a residual liability until then.\n\n== Status model\n\n[source,typescript]\n----\nexport enum CancelationStatus {\n    Canceled = 'canceled',\n    Pending  = 'pending',\n}\n----\n\n[cols=\"2,3\"]\n|===\n| `null` | Not cancelling.\n| `Pending` | Cancellation requested; opt-out dates are running.\n| `Canceled` | Cancellation complete. Combined with `supplierStatus === Suspended`, `selectIsSupplierCancelled` returns true.\n|===\n\n== Availability\n\n`CancellationWorkflowService` decides which action to offer by reading\n`cancellationPermissions` off the supplier status:\n\n[source,typescript]\n----\nthis.store.select(selectCurrentUserId).pipe(\n    filter(supplierKey => coerceBooleanProperty(supplierKey)),\n    take(1),\n    switchMap(supplierKey => this.suppliersService.getSupplierStatus(supplierKey)),\n    pluck('cancellationPermissions'),\n).subscribe(v => { this.showBtn(v); this.loadingActualStatusSubject$.next(false); });\n----\n\n[source,typescript]\n----\nexport enum CancellationWorkflowOptionAvailableEnum {\n    DEACTIVATE = 'DEACTIVATE',\n    REACTIVATE = 'REACTIVATE',\n    NONE       = 'NONE',\n}\n----\n\n`canReactivate` takes precedence over `canCancel`; if neither is set the section\nrenders nothing. The service exposes `activeOption$`, `loadingActualStatus$` and\n`showLoaderOnButton$` so the UI can show a skeleton while the status resolves.\n\n[NOTE]\n====\nThe service calls `getSupplierStatus()` directly rather than reading the auth\nslice, because `cancellationPermissions` is not carried on the `Status` model.\n====\n\n== Deactivation\n\n[listing]\n----\nsupplier clicks \"Deactivate account\"\n  → track(CancellationWorkflowEventType.DeactivateAccountStartBtnClicked)\n  → startDeactivateAccountProcess()\n  → confirmation modal (MessageModalService)\n  → cancellation survey\n        CancellationSurveyShowWhenDeactivatingAccountService\n        → track(CancellationWorkflowEventType.SubmittingTheCancellationSurvey)\n  → CancellationWorkflowApiService submits the request\n  → success toast (PybToastService)\n  → cancelationStatus becomes Pending\n----\n\n== Reactivation\n\n[listing]\n----\nsupplier clicks \"Reactivate account\"\n  → track(CancellationWorkflowEventType.ReactivateAccountStartBtnClicked)\n  → confirmation modal\n  → track(CancellationWorkflowEventType.ReactivateAccountConfirmBtnClicked)\n  → CancellationWorkflowApiService reactivation call\n----\n\n== The cancellation survey\n\n`views/partials/modals/cancellation-survey/`, registered globally by\n`CancellationSurveyModule` in `AppModule` so it can be opened from anywhere.\n\nFields:\n\n[source,typescript]\n----\nexport enum CancellationSurveyFormFieldsTypes {\n    REASON         = 'reasonCode',\n    COMMENT        = 'comment',\n    RECOMMENDATION = 'rating',\n}\n----\n\nReason options arrive on the `cancellation_survey` dashboard notification as\n`DashboardNotificationCancellationSurveyOptionDataDto[]`, so the option list is\nserver-controlled and can change without a release.\n\nThe survey is also reachable from the notification itself, not only from the\ndeactivation flow.\n\n== Cancellation banners\n\n`CancellationBannerInitializerService` runs from\n`GlobalSubscriptionsService.init()` and only acts when\n`cancelationStatus === Pending`. It fetches the supplier's marketplaces and\npartitions them by opt-out date.\n\n[source,typescript]\n----\nprivate isOptOutDateSet(optOutDate: string): boolean {\n    return optOutDate && new PybDate(optOutDate, this.rawDateFormat).get('year') !== 9999;   // <1>\n}\n\nprivate pendingCancellationFilter = (mp: SupplierMarketplacesModel): boolean => {\n    const optOutDate = new PybDate(mp.fundingParticulars.lastOptOutDate, this.rawDateFormat);\n    return new PybDate().isBefore(optOutDate, 'd');                                          // <2>\n};\n\nprivate reachedCancellationFilter = (mp) => !this.pendingCancellationFilter(mp);\n----\n<1> Year `9999` is the sentinel for \"no opt-out date\" — such marketplaces are\n    excluded entirely.\n<2> Opt-out still in the future → *pending*. Otherwise → *reached*.\n\n=== Pending banner\n\nComposed from three translation keys:\n\n[cols=\"2,3\"]\n|===\n| `BANNERS.CANCELLATION.PENDING.YOUR_ACCOUNT` | Lead sentence.\n| `BANNERS.CANCELLATION.PENDING.LAST_DAY` | One per marketplace, interpolating `marketplaceName` and `optOutDate` (reformatted `yyyy-MM-dd` → `MM/dd/yyyy`).\n| `BANNERS.CANCELLATION.READ_MORE` | Trailing link.\n|===\n\n=== Reached banner\n\nLists the marketplaces whose opt-out date has passed, then branches on the\nsupplier's potential liability, read from the balance slice:\n\n[cols=\"2,3\"]\n|===\n| `potentialLiability > 0` | `BANNERS.CANCELLATION.REACHED.CANCELLATION_LIABILITY` with the amount, plus `READ_MORE`.\n| otherwise | `BANNERS.CANCELLATION.REACHED.CANCELLATION_COMPLETE`.\n|===\n\nBoth banners are `BannerTypes.WARNING` with `hideClose: false`, and previous\nbanners are cleared before new ones are shown so repeated evaluation cannot stack\nduplicates.\n\n== Account-cancelled banner\n\n`InternalMessagesService.initAccountCancelledBanner()`, also called from\n`GlobalSubscriptionsService.init()`, shows the terminal banner once the account is\nfully cancelled.\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `DeactivateAccountStartBtnClicked` | `Clicked_Deactivate_Account_Button`\n| `ReactivateAccountStartBtnClicked` | `Clicked_Reactivate_Account_Button`\n| `ReactivateAccountConfirmBtnClicked` | `Clicked_Reactivate_Account_Confirm_Button`\n| `SubmittingTheCancellationSurvey` | `Submitting_The_Cancellation_Survey`\n|===\n\n[NOTE]\n====\nDeactivation has a *start* event but no *confirm* event, while reactivation has\nboth. Funnel analysis of completed deactivations must use the survey-submission\nevent as the proxy.\n====\n\n== `FeedbackService`\n\nAn unrelated small service despite the name — it opens `SwitchCdDialogComponent`\n(the checking/deposit account switch dialog) with an optional `revert` flag. It\nis not part of the cancellation survey.\n"},{"component":"supplier-client","module":"ROOT","path":"business/suspension.adoc","title":"Suspension","description":"Suspension reasons, the reason-to-copy resolution rules, and the remediation modal.","url":"https://docx.payability.com/supplier-client/business/suspension.html","navOrder":32,"source":"= Suspension\n:description: Suspension reasons, the reason-to-copy resolution rules, and the remediation modal.\n\nA suspended supplier keeps read access and the `TRANSFER_FUNDS` permission, but\nloses the ability to draw new advances. The client's job is to explain *why* and,\nwhere possible, offer a fix.\n\n== Reason codes\n\n[source,typescript]\n----\n// src/app/core/pyb/enums/suspension-reason.enum.ts\nexport enum SuspensionReason {\n    AccountAtRisk                   = 'sh.susp:acc_at_risk',\n    ApiNeeded                       = 'sh.susp:api_needed',\n    Cancellation                    = 'sh.susp:cancellation',\n    InvalidCreditCard               = 'sh.susp:inv_credit_card',\n    MarketplaceAccessDiverted       = 'sh.susp:access_diverted',\n    MarketplacePayeeDiverted        = 'sh.susp:payee_diverted',\n    MarketplacePermissionsIncorrect = 'sh.susp:perm_inc',\n    SellingPrivilegesRemoved        = 'sh.susp:sell_prv_rmv',\n    SuspendedByMarketplace          = 'sh.susp:susp_by_mp',\n    FailedPayment                   = 'sh.susp:failed_payments',\n}\n----\n\nOnly a subset produces a modal:\n\n[source,typescript]\n----\nexport const supportedSuspensionReasons = [\n    SuspensionReason.AccountAtRisk,\n    SuspensionReason.InvalidCreditCard,\n    SuspensionReason.MarketplaceAccessDiverted,\n    SuspensionReason.MarketplacePayeeDiverted,\n    SuspensionReason.MarketplacePermissionsIncorrect,\n    SuspensionReason.SellingPrivilegesRemoved,\n    SuspensionReason.SuspendedByMarketplace,\n    SuspensionReason.FailedPayment,\n];\n----\n\n`ApiNeeded` and `Cancellation` are deliberately excluded — they are handled by the\nAPI-connector banners and the cancellation banners respectively.\n\n== Trigger\n\n`SuspensionReasonInitializerService.initialize()` merges two streams and reacts to\neither, but only while the supplier is on the dashboard:\n\n[source,typescript]\n----\nthis.subscription = merge(this.onToDashboardNavigation(), this.onUserSuspended())\n    .subscribe(() => this.isOnDashboardPage() && this.checkUserStatus());\n----\n\n[cols=\"2,3\"]\n|===\n| `onToDashboardNavigation()` | `NavigationEnd` events whose `urlAfterRedirects` includes `/dashboard`.\n| `onUserSuspended()` | `supplierStatus` transitioning to `Suspended`.\n|===\n\n`initialize()` unsubscribes any prior subscription first, so it is safe to call\nmore than once.\n\n== Eligibility for the modal\n\nThe reason and its marketplace come from the `supplier_suspension` dashboard\nnotification's `data` (`SuspensionNotificationDataDto`):\n\n[source,typescript]\n----\nexport type SuspensionNotificationDataDto = {\n    reasonCode: SuspensionReason;\n    reasonCount?: number;\n    lastMpStatementEndDate?: string;\n    marketplace?: { marketplace_name: string; marketplace_key: string };\n};\n----\n\n[source,typescript]\n----\nprivate isSuspensionReasonSupported = (suspensionData: SuspensionNotificationDataDto): boolean => {\n    const isSupported = supportedSuspensionReasons.includes(suspensionData?.reasonCode)\n                     && !!suspensionData?.marketplace;                                   // <1>\n    if (!isSupported) { return false; }\n\n    if (reasonsWithMarketplaceVariance.includes(suspensionData?.reasonCode)) {\n        return [MarketplaceEnum.Amazon.valueOf(),\n                MarketplaceEnum.Walmart.valueOf(),\n                MarketplaceEnum.Payoneer.valueOf()].includes(suspensionData.marketplace.marketplace_key);   // <2>\n    }\n    return true;\n};\n----\n<1> A marketplace is *required* — a supported reason without one shows nothing.\n<2> Reasons whose copy varies by marketplace are only shown for Amazon, Walmart\n    and Payoneer; other channels have no written variant.\n\n[source,typescript]\n----\nexport const reasonsWithMarketplaceVariance = [\n    SuspensionReason.MarketplacePermissionsIncorrect,\n    SuspensionReason.MarketplacePayeeDiverted,\n    SuspensionReason.MarketplaceAccessDiverted,\n];\n----\n\n== Copy resolution\n\n[source,typescript]\n----\nexport const copyNamespace = 'SUSPENSION_REASON';\n\nexport const suspensionMarketplaceMap = {\n    [MarketplaceEnum.Amazon]:   'amazon',\n    [MarketplaceEnum.Walmart]:  'walmart',\n    [MarketplaceEnum.Payoneer]: 'walmart',      // <1>\n};\n\nexport const produceTranslationPath = (reason: SuspensionReason, marketplaceKey?: string): string => {\n    const [, reasonCode] = reason.split(':');   // <2>\n\n    if (reasonsWithMarketplaceVariance.includes(reason) && Object.keys(suspensionMarketplaceMap).includes(marketplaceKey)) {\n        return `${copyNamespace}.${reasonCode}.${suspensionMarketplaceMap[marketplaceKey]}`;\n    }\n    return `${copyNamespace}.${reasonCode}`;\n};\n----\n<1> Payoneer intentionally reuses the *Walmart* copy — the remediation steps are\n    the same.\n<2> The `sh.susp:` prefix is stripped, so `sh.susp:acc_at_risk` resolves to\n    `SUSPENSION_REASON.acc_at_risk`, and the Amazon variant of\n    `sh.susp:perm_inc` resolves to `SUSPENSION_REASON.perm_inc.amazon`.\n\n=== Failed payments\n\nFailed-payment copy escalates with the attempt count:\n\n[source,typescript]\n----\nif (suspensionData.reasonCode === SuspensionReason.FailedPayment) {\n    customizedReasonCode = (suspensionData.reasonCode +\n        (!suspensionData.reasonCount || suspensionData.reasonCount <= 1 ? '.first' : '.next')) as unknown as SuspensionReason;\n}\n----\n\ngiving `SUSPENSION_REASON.failed_payments.first` or `.next`.\n\n== The modal\n\nBuilt by `MessageModalService` with:\n\n[cols=\"2,3\"]\n|===\n| Title | `SUSPENSION_REASON.TITLE`\n| Description | `produceTranslationPath(reason, marketplaceKey)`, interpolating `marketplaceName` and the full `data` object.\n| Button | `COMMON.NEXT` for Amazon + `MarketplaceAccessDiverted`; `COMMON.CLOSE` otherwise. Non-Amazon marketplaces always get `COMMON.CLOSE`.\n| Close | `hideClose: false`.\n|===\n\n`lastMpStatementEndDate` is reformatted from `yyyy-MM-dd` to `MM/dd/yyyy` before\ninterpolation, inside a `try/catch` so an unparseable date does not block the\nmodal.\n\nA `this.modal` field ensures only one suspension modal exists at a time; it is\ncleared in `afterClosed()`.\n\n== Remediation\n\nOne reason has an actionable follow-up:\n\n[source,typescript]\n----\nconst shouldUseAmazonHandler =\n    suspensionData?.reasonCode === SuspensionReason.MarketplaceAccessDiverted &&\n    suspensionData.marketplace.marketplace_key === MarketplaceEnum.Amazon;\n\nthis.modal.afterClosed().pipe(take(1)).subscribe(result => {\n    if (result && shouldUseAmazonHandler) {\n        this.store.dispatch(openAmazonInviteWrapper());   // re-invite Payability on Amazon\n    }\n    this.modal = null;\n});\n----\n\nAmazon access diverted → the modal's `COMMON.NEXT` button opens the Amazon\ninvite-Payability wizard directly.\n\n== Related surfaces\n\n[cols=\"2,3\"]\n|===\n| `modals/amazon-suspended/` | The supplier's *Amazon* account is suspended — distinct from a Payability suspension.\n| `BannerService` | Persistent suspension banner outside the dashboard.\n| `selectIsSupplierCancelled` | `Suspended` + `cancelationStatus === Canceled` means cancelled, not suspended — see xref:business/cancellation.adoc[Cancellation & Reactivation].\n|===\n\n== Permissions while suspended\n\n[cols=\"2,3\"]\n|===\n| Kept | `TRANSFER_FUNDS` — `Active` **or** `Suspended` grants it.\n| Lost | `ADVANCE_LINE.WITHDRAW_ADVANCE_LINE` — explicitly requires `supplierStatus !== Suspended`.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"features/overview.adoc","title":"Feature Pages Overview","description":"Every lazy-loaded page, its route, its gating, and where it is documented in depth.","url":"https://docx.payability.com/supplier-client/features/overview.html","navOrder":33,"source":"= Feature Pages Overview\n:description: Every lazy-loaded page, its route, its gating, and where it is documented in depth.\n\nFeature pages live under `src/app/views/pages/` (plus `src/app/views/onboarding/`\nfor the onboarding flow) and are lazy-loaded from\n`src/app/app-routing.module.ts`.\n\n== Page index\n\n[cols=\"2,2,2,3\"]\n|===\n| Route | Module | Gating | Detail\n\n| `/auth`, `/login`\n| `AuthModule`\n| `LogOutGuard`\n| xref:security/authentication.adoc[Authentication]\n\n| `/dashboard`\n| `DashboardModule`\n| `DocuSignGuard`\n| xref:features/dashboard.adoc[Dashboard]\n\n| `/manage-account`\n| `ManageAccountModule`\n| `TwoFactorAuthenticationGuard` with `force2FAConfirmation`\n| xref:features/manage-account.adoc[Manage Account]\n\n| `/reports`\n| `ReportsModule`\n| `REPORTS` permission gates the menu entry\n| xref:features/reports.adoc[Reports]\n\n| `/daily-summary-report`\n| `DailySummaryModule`\n| `NgxPermissionsGuard` on `REPORTS.DAILY_SUMMARY`\n| xref:features/reports.adoc[Reports]\n\n| `/seller-card`\n| `SellerCardModule`\n| `SellerCardListGuard`\n| xref:features/seller-card-pages.adoc[Seller Card Pages]\n\n| `/newcard`\n| `NewCardModule`\n| —\n| xref:features/seller-card-pages.adoc[Seller Card Pages]\n\n| `/my-marketplaces`\n| `MyMarketplacesPageModule`\n| `MY_MARKETPLACES` (default permission)\n| xref:features/my-marketplaces.adoc[My Marketplaces]\n\n| `/product-offerings`\n| `ProductOfferingsModule`\n| `DocuSignGuard`\n| xref:features/product-offerings.adoc[Product Offerings]\n\n| `/onboarding`\n| `OnboardingModule`\n| —\n| xref:business/onboarding.adoc[Onboarding]\n\n| `/vendor-pay`\n| `VendorPayIncomingReportsModule`\n| `VENDOR_PAY`\n| xref:business/vendor-pay.adoc[Vendor Pay]\n\n| `/send-money`\n| `SendMoneyModule`\n| `VENDOR_PAY`\n| xref:business/vendor-pay.adoc[Vendor Pay]\n\n| `/partners`\n| `PartnersModule`\n| —\n| xref:features/other-pages.adoc[Other Pages]\n\n| `/referral`\n| `MoreFromPayabilityModule`\n| `REFERRAL`\n| xref:features/other-pages.adoc[Other Pages]\n\n| `/payshield-subscription`\n| `PayshieldSubscriptionModule`\n| —\n| xref:features/other-pages.adoc[Other Pages]\n\n| `/blog`\n| `BlogPageModule`\n| —\n| xref:features/other-pages.adoc[Other Pages]\n\n| `/sales-call-summary`\n| `SalesCallSummaryModule`\n| —\n| xref:features/other-pages.adoc[Other Pages]\n\n| `/help-center`\n| — (guard-only)\n| `HelpCenterRedirectGuard`\n| xref:routing/guards.adoc[Route Guards]\n|===\n\n== The shell\n\nEvery authenticated route renders inside `BaseComponent`\n(`src/app/views/theme/base/`), which provides:\n\n[cols=\"2,3\"]\n|===\n| `theme/header/` | Top navigation, built from `MenuConfig` and filtered by permission. Includes `header-mobile/`.\n| `theme/aside/` | Side navigation and `user-details/`.\n| `theme/subheader/` | Page title and breadcrumbs, fed by `SubheaderService`.\n| `theme/footer/` | Footer with build version.\n| `theme/brand/` | Logo and white-label branding.\n| `partials/layout/splash-screen/` | Bootstrap loading screen.\n|===\n\nGlobal banners are rendered by the shell and managed by `BannerService`.\n\n== Common page anatomy\n\n[source]\n----\npages/<feature>/\n├── <feature>.module.ts        declarations + RouterModule.forChild(routes)\n├── <feature>.component.ts     the page component (or <feature>.page.ts)\n├── <feature>.component.html\n├── <feature>.component.scss\n├── <feature>.service.ts       feature-scoped service (provided by the module)\n├── <feature>.config.ts        route table / column config / constants\n├── <feature>.enums.ts\n├── <feature>.types.ts\n└── <sub-feature>/             nested lazy modules\n----\n\nPage components typically:\n\n* extend `Subscriber` and pipe through `takeUntil(this.ngUnsubscribe$)`,\n* use `ChangeDetectionStrategy.OnPush`,\n* set the page title through `SubheaderService`,\n* select from the store rather than calling services directly,\n* dispatch a load action in `ngOnInit`.\n\n== Shared building blocks\n\n[cols=\"2,3\"]\n|===\n| `partials/widgets/` | Store-connected cards, mostly for the dashboard.\n| `partials/modals/` | Dialogs opened by `MatDialog` or `PybDialogService`.\n| `partials/components/` | Presentational primitives.\n| `partials/content/` | CTA strategies, export dialogs, the TabaPay iframe.\n| `templates/` | Reusable form templates such as `simple-address-form`.\n| `core/pyb/components/grid/` | AG Grid cell renderers.\n|===\n\nFull inventory: xref:reference/components.adoc[Shared Components].\n\n== Environment redirection\n\n`DashboardPageHelperService` handles a supplier landing on the wrong environment\nhost. It compares the environment segment of the current host with the supplier's\nown site and, if they differ, rebuilds the URL and redirects:\n\n[source,typescript]\n----\nredirectToSupplierSite(supplierSite: string, currentHost: string, token = ''): void {\n    const hostPart  = this.generateHostPart(currentHost, supplierSite);\n    const loginPart = token ? `/#/login/auth?token=${token}` : '';\n    const protocol  = this.windowRef.nativeWindow.location.protocol;\n    this.windowRef.nativeWindow.location.href = `${protocol}//${hostPart}${loginPart}`;\n}\n----\n\nThe host is split on `.` to take the environment segment, then on `-` to take the\ndashboard segment, so `dev1-sd.payability.com` and `dev1-crm.payability.com` are\nrecognised as the same environment with different dashboards. When a token is\nsupplied the supplier is dropped straight into an authenticated session on the\ncorrect host.\n"},{"component":"supplier-client","module":"ROOT","path":"features/auth-pages.adoc","title":"Auth Pages","description":"The unauthenticated screens — login, registration, password recovery, new password and staff impersonation.","url":"https://docx.payability.com/supplier-client/features/auth-pages.html","navOrder":34,"source":"= Auth Pages\n:description: The unauthenticated screens — login, registration, password recovery, new password and staff impersonation.\n\n`src/app/views/pages/auth/` is the only feature area that renders *outside*\n`BaseComponent`. It has its own shell, `AuthComponent`, and is the module that\nregisters the `auth` store slice.\n\nThe mechanics behind these screens — `AuthService`, the effects, the token — are\ndocumented in xref:security/authentication.adoc[Authentication]. This page covers\nthe component layer.\n\n== Routes\n\nAll children of `AuthComponent`:\n\n[cols=\"2,2,2,2\"]\n|===\n| Path | Component | Guard | Purpose\n\n| `''` | — | — | Redirects to `login`.\n| `login` | `LoginComponent` | `LogOutGuard` | Email + password sign-in.\n| `auth` | `LoginAsSupplierComponent` | — | Staff impersonation via a pre-issued token.\n| `register` | `RegisterComponent` | `LogOutGuard` | Self-service account creation.\n| `reset-password` | `RecoverPasswordComponent` | `LogOutGuard` | Request a reset email.\n| `new-password` | `NewPasswordComponent` | `NewPasswordTokenGuard` | Set a new password from an emailed code.\n|===\n\n`LogOutGuard` keeps an already-authenticated user off these screens;\n`NewPasswordTokenGuard` validates the `?r=` code before the form renders. See\nxref:routing/guards.adoc[Route Guards].\n\n[NOTE]\n.The login route captures a return URL at module evaluation time\n====\n[source,typescript]\n----\n{\n    path: 'login',\n    component: LoginComponent,\n    data: { returnUrl: window.location.pathname },\n    canActivate: [LogOutGuard],\n}\n----\n\n`window.location.pathname` is read once, when the route array is constructed — not\nper navigation. With hash routing the pathname is almost always `/`, so this\n`data` is effectively a constant. The value the component actually honours is the\n`returnUrl` *query parameter*, defaulting to `/`.\n====\n\n== `AuthModule`\n\nUnusually for a feature module, `AuthModule` owns store registration and several\nproviders:\n\n[source,typescript]\n----\nStoreModule.forFeature('auth', authReducer, AUTH_CONFIG_TOKEN),\nEffectsModule.forFeature([AuthEffects, UserStatusEffects]),\n----\n\n[cols=\"2,3\"]\n|===\n| `AUTH_LOCAL_STORAGE_KEY` | `'__auth_storage__'` — the localStorage key for the persisted slice.\n| `AUTH_STORAGE_KEYS` | `['auth']` — which slices the meta-reducer persists.\n| `AUTH_CONFIG_TOKEN` | Factory that builds `{ metaReducers: [storageMetaReducer(…)] }`.\n| `RECAPTCHA_V3_SITE_KEY` | `environment.recaptchaKey`.\n|===\n\n`forRoot()` additionally provides `AuthService`, `AuthGuard`,\n`DashboardStatusGuard`, `LogOutGuard` and `NewPasswordTokenGuard` — which is why\n`AppModule` imports `AuthModule.forRoot()` even though the routes are lazy.\n\n[IMPORTANT]\n====\nThe `auth` slice is registered by a *lazy* module. Anything that selects from\n`auth` before the auth bundle has loaded gets `undefined`. In practice the module\nis always loaded, because `forRoot()` pulls it into the main bundle — but do not\nrely on the slice existing in an isolated test without importing `AuthModule`.\n====\n\nInput controls come from `@payability/pyb-ngx-components` (`PybTextInputModule`,\n`PybPasswordInputModule`, `PybFormFieldModule`, `PybLabelModule`, `PybErrorModule`,\n`PybStandardButtonModule`) rather than raw Material inputs. `RecaptchaComponent` is\ndeclared here directly from `core/pyb/components/recaptcha/`.\n\n== Shared field constraints\n\n`auth.enum.ts` and `auth.const.ts` hold the control names and length bounds shared\nby login and registration:\n\n[source,typescript]\n----\nexport const pybAuthControlLength = new Map<PybAuthControls, PybMinMax>([\n    [PybAuthControls.FirstName,   { min: 3, max: 100 }],\n    [PybAuthControls.LastName,    { min: 3, max: 100 }],\n    [PybAuthControls.CompanyName, { min: 3, max: 200 }],\n    [PybAuthControls.Phone,       { min: 5, max: 15  }],\n    [PybAuthControls.Email,       { min: 3, max: 320 }],\n    [PybAuthControls.Password,    { min: 8, max: 100 }],\n]);\n----\n\nBoth forms build validators from this map rather than repeating numbers, so\nchanging a bound here changes both screens.\n\n== `LoginComponent`\n\n`submit()` chains reCAPTCHA, login, and a host check:\n\n[source,typescript]\n----\nthis.recaptchaV3Service.execute('login').pipe(\n    switchMap(recaptcha =>\n        this.auth.login(authData.email, authData.password, recaptcha).pipe(\n            catchError(err => {\n                if (err.error?.error_code !== 'ACCOUNT_LOCKED') {                      // <1>\n                    this.authNoticeService.setNotice(this.translate.instant('AUTH.VALIDATION.INVALID_LOGIN'), 'danger');\n                }\n                this.loading = false;\n                return throwError(err);\n            }),\n            map(data => {\n                const { supplier_site: supplierSite } = jwtDecode(data.token);         // <2>\n                const currentHost = window.location.host;\n                if (!this.isLocalHost() && !this.dashboardPageHelperService.isOnCorrectSite(supplierSite, currentHost)) {\n                    this.dashboardPageHelperService.redirectToSupplierSite(supplierSite, currentHost, token);\n                    this.unsubscribe.next();\n                    return;                                                            // <3>\n                }\n                this.store.dispatch(login({ authToken: data.token }));\n            }),\n        ),\n    ),\n).subscribe();\n----\n<1> A locked account is deliberately *not* given the generic invalid-credentials\n    notice. `HttpErrorHandlerInterceptor` already matches\n    `401 + error_code === 'ACCOUNT_LOCKED'` and raises a 30-second toast telling the\n    supplier to email support; a \"wrong email or password\" banner underneath it\n    would contradict the real reason.\n<2> The JWT is decoded client-side to read `supplier_site`, before any store\n    dispatch.\n<3> If the supplier belongs to a different host, the component redirects there\n    *carrying the token* and never dispatches `login`. The session therefore starts\n    on the correct host. Skipped on `localhost`.\n\nThe reCAPTCHA action name is `'login'`; the recovery screen uses\n`'reset_password_start'`. See xref:integrations/recaptcha.adoc[reCAPTCHA].\n\n=== The create-account link is host-derived\n\nThere is no route for the marketing sign-up flow, so the link is computed from the\ncurrent URL:\n\n[source,typescript]\n----\nget siteVerision(): string {                        // <1>\n    let regex, replaceValue;\n    if (this.isNewEggPage()) {\n        regex = /neweggcapital2/gi;\n        replaceValue = 'newegg-onboarding';\n    } else {\n        regex = /supplier\\d?/gi;\n        replaceValue = 'onboarding';\n    }\n    return window.location.href.replace(regex, replaceValue).split('.')[0];\n}\n\nget createAccountUrl(): string {\n    if (this.isLocalHost()) {\n        return '#/auth/register';\n    }\n    return `${this.siteVerision}.${this.topLevelDomain}/#/login/create`;\n}\n----\n<1> Misspelled in the source (`siteVerision`).\n\nSo `supplier.payability.com` sends new users to `onboarding.payability.com`, and a\nNewEgg-branded host sends them to `newegg-onboarding…`. `topLevelDomain` picks\n`dev.payability.com` when the URL contains `.dev`.\n\n[WARNING]\n====\n`/auth/register` is reachable *only on localhost* through this link. In every\ndeployed environment the button leaves the application, so `RegisterComponent` is\neffectively dev-only UI even though the route is live. Anyone who navigates\ndirectly to `#/auth/register` in production still gets a working form.\n====\n\n`loginViaSocialMediaIsAvailable` is a hardcoded `false` that hides the social\nbuttons inherited from the original template.\n\n== `RegisterComponent`\n\nBuilds a seven-control form with a cross-field validator:\n\n[source,typescript]\n----\nthis.registerForm = this.fb.group({ /* … */ }, {\n    validator: ConfirmPasswordValidator.MatchPassword,\n});\n----\n\n[cols=\"1,3\"]\n|===\n| `firstName`, `lastName` | Required, 3–100, pattern `[A-Z]{1,1}[a-z]+`.\n| `companyName` | Required, 3–200.\n| `phone` | Required, 5–15, pattern `^((\\+)\\|(00)\\|(\\*)\\|())[0-9]{5,15}$`.\n| `email` | Required, `Validators.email`, 3–320, plus an additional pattern.\n| `password`, `confirmPassword` | Required, 8–100, matched by the group validator.\n| `agree` | `Validators.required` on a boolean.\n|===\n\n[WARNING]\n.Three validation quirks worth knowing\n====\n* The name pattern `[A-Z]{1,1}[a-z]+` is unanchored, so it demands that a\n  capital-then-lowercase sequence appear *somewhere* — `o'Brian` passes,\n  `MCDONALD` fails.\n* The email pattern ends `\\.[a-z]{2,3}`, which rejects any four-letter TLD:\n  `.info`, `.name`, `.shop`. `Validators.email` alone would have accepted them.\n* `Validators.required` on the `agree` checkbox does not work — `false` is a\n  present value. The component compensates with an explicit check in `submit()`,\n  which sets a notice but *returns without resetting `loading`*, leaving the button\n  in its loading state.\n====\n\n`ConfirmPasswordValidator.MatchPassword` sets the error on the control rather than\nreturning it:\n\n[source,typescript]\n----\nstatic MatchPassword(control: AbstractControl) {\n    const password = control.get('password').value;\n    const confirmPassword = control.get('confirmPassword').value;\n    if (password !== confirmPassword) {\n        control.get('confirmPassword').setErrors({ ConfirmPassword: true });\n    } else {\n        return null;\n    }\n}\n----\n\nIt returns `undefined` in the failure branch, so the *group* stays valid while the\n`confirmPassword` control carries a `ConfirmPassword` error. Templates must check\nthe control, not the form. The mismatch branch also overwrites any other errors on\nthat control.\n\nSubmission builds a `User` model by hand, calls `AuthService.register()` and\ndispatches `register({ authToken, userId })`.\n\n== `RecoverPasswordComponent` — `/auth/reset-password`\n\nA single email field, dispatching `recoverPasswordEmailRequest({ email, token })`\nafter a reCAPTCHA execution. State comes from the store:\n`selectIsPendingRecoverPasswordRequest`,\n`selectIsRecoverPasswordRequestSent`, `selectRecoverPasswordRequestError`.\n\nA backend failure is translated into a field-level error rather than a banner:\n\n[source,typescript]\n----\nthis.recoverPasswordRequestError$.pipe(takeUntil(this.destroyed.asObservable())).subscribe(() => {\n    this.formGroup.controls.email.setErrors({ emailNotFound: true });\n});\n----\n\nThe screen has a second mode. With `?required=<email>` the address is pre-filled\nand *disabled*, and `isPasswordChangeRequest` is set — used when the backend\nforces a password change for a known account:\n\n[source,typescript]\n----\nemail: [\n    !!defaultEmail ? decodeURIComponent(defaultEmail) : '',\n    [Validators.required, Validators.email, Validators.minLength(3), Validators.maxLength(320)],\n]\n----\n\n[WARNING]\n====\n`ngOnDestroy` calls `this.captchaSub$.unsubscribe()` unconditionally, but\n`captchaSub$` is only assigned in `submitClick()`. Leaving the page without\nsubmitting throws a `TypeError` on destroy. It is swallowed by Angular's error\nhandler and does not break navigation, but it does appear in the console and in\nSentry-style error reporting.\n====\n\n== `NewPasswordComponent` — `/auth/new-password`\n\nReached from the reset email, which carries `?r=<verificationCode>`.\n`NewPasswordTokenGuard` has already confirmed the code by the time the form\nrenders; the component re-reads it from the snapshot on submit:\n\n[source,typescript]\n----\nconst { password } = this.formGroup.value;\nconst { r: verificationCode } = this.activatedRoute.snapshot.queryParams;\nthis.store.dispatch(newPasswordRequest({ password, verificationCode }));\n----\n\nPassword rules and error copy live in `new-password.constants.ts`:\n\n[source,typescript]\n----\nexport const passwordValidationPattern = /(?=.*?[A-Z])(?=.*?[0-9]).{8,}/;\n\nexport const formErrorsMap: Dictionary<string> = {\n    required:        'AUTH.VALIDATION.REQUIRED_FIELD',\n    pattern:         'AUTH.VALIDATION.PASSWORD_INVALID',\n    ConfirmPassword: 'AUTH.VALIDATION.PASSWORDS_NOT_MATCHING',\n};\n----\n\nAt least one uppercase letter, at least one digit, minimum eight characters. No\nsymbol is required and none is forbidden. `ConfirmPassword` in the error map is\nthe key set by `ConfirmPasswordValidator`, reused here from the register folder.\n\nSuccess navigates to login and raises a `BannerTypes.SUCCESS` banner; failure\nraises `BannerTypes.DANGER`. `ngOnDestroy` dispatches `newPasswordRequestReset()`\nso a revisit starts clean.\n\n`new-password-form/` and `recover-password-form/` are dumb presentational bodies,\neach taking `[formGroup]` and `[errorsMap]` inputs and used by exactly one parent:\n\n[source,html]\n----\n<pyb-new-password-form [formGroup]=\"formGroup\" [errorsMap]=\"errorsMap\"></pyb-new-password-form>\n----\n\nThe split keeps the store wiring in the routed component and the markup testable\non its own. Neither is reused outside `AuthModule`, so a change to either affects\none screen only.\n\n== `LoginAsSupplierComponent` — `/auth/auth`\n\nThe impersonation and cross-host landing target. It does one thing:\n\n[source,typescript]\n----\nthis.activatedRoute.queryParams.subscribe(params => {\n    const authToken = params.token;\n    this.store.dispatch(login({ authToken }));\n});\n----\n\nThen waits for `selectIsUserLoaded` and navigates to `/`. It is also where\n`LoginComponent`'s cross-host redirect lands, since\n`redirectToSupplierSite()` builds `…/#/login/auth?token=<token>`.\n\n[WARNING]\n====\nThere is no validation of `params.token`. A missing or malformed token is\ndispatched anyway; the failure surfaces later, when `getUserByToken()` returns\n401 and `UnauthorizedInterceptor` bounces the user to login. The whole session\ntherefore hinges on a URL query parameter — treat impersonation links as\ncredentials.\n====\n\n== `AuthNoticeService` and `AuthNoticeComponent`\n\nThe banner mechanism for these screens, separate from the authenticated\n`BannerService`. `AuthNoticeService.setNotice(message, type)` pushes to\n`onNoticeChanged$`; `pyb-auth-notice` renders it.\n\nNotices survive a navigation *within* the auth area, which is how registration\nsuccess is shown on the login screen:\n\n[source,typescript]\n----\nthis.authNoticeService.setNotice(this.translate.instant('AUTH.REGISTER.SUCCESS'), 'success');\n----\n\n`LoginComponent` clears the notice in both `ngOnInit` and `ngOnDestroy`, so a\nnotice set *before* navigating to login survives, but one set earlier in the\nsession does not linger.\n\n[NOTE]\n====\n`AuthNoticeComponent` declares `message` and `type` as `@Output()` although they\nare consumed as internal state — a template-era artefact. They are not real\noutputs; nothing binds to them.\n====\n\n== Related pages\n\n* xref:security/authentication.adoc[Authentication] — services, effects, token handling\n* xref:state/auth-slice.adoc[Auth Slice] — actions and selectors used above\n* xref:routing/guards.adoc[Route Guards] — `LogOutGuard`, `NewPasswordTokenGuard`\n* xref:integrations/recaptcha.adoc[reCAPTCHA]\n* xref:security/two-factor.adoc[Two-Factor Authentication] — the challenge that follows login\n"},{"component":"supplier-client","module":"ROOT","path":"features/onboarding-pages.adoc","title":"Onboarding Pages","description":"The /onboarding route tree — status top bar, product offering, repayment information, sign agreement, underwriting review and the loading dialog.","url":"https://docx.payability.com/supplier-client/features/onboarding-pages.html","navOrder":35,"source":"= Onboarding Pages\n:description: The /onboarding route tree — status top bar, product offering, repayment information, sign agreement, underwriting review and the loading dialog.\n\n`src/app/views/onboarding/` is the UI for pre-activation. The business rules —\nschemas, which products unlock when, compliance records — are in\nxref:business/onboarding.adoc[Onboarding]. This page documents the screens and the\nservice that drives them.\n\nUnlike every other feature area, onboarding has *two* presences:\n\n[cols=\"1,3\"]\n|===\n| `/onboarding/*` | Four routed step pages under `OnboardingComponent`.\n| The top bar | A status strip rendered inside `BaseComponent` on *every* authenticated page while the supplier is pre-activation.\n|===\n\n== Route tree\n\n`OnboardingModule` declares only `OnboardingComponent`, an empty shell whose\ntemplate is a router outlet. Every step is separately lazy-loaded:\n\n[source,typescript]\n----\nconst routes: Routes = [{\n    path: '',\n    component: OnboardingComponent,\n    children: [\n        { path: 'repayment-information', loadChildren: () => import('./repayment-information/repayment-information.module')… },\n        { path: 'underwriting-review',   loadChildren: () => import('./underwriting-review/underwriting-review.module')… },\n        { path: 'product-offering',      loadChildren: () => import('./product-offering/product-offering.module')… },\n        { path: 'sign-agreement',        loadChildren: () => import('./sign-agreement/sign-agreement.module')… },\n    ],\n}];\n----\n\n[NOTE]\n====\n`OnboardingModule` also imports `RepaymentInformationModule` eagerly *and*\nlazy-loads it as a child route. The eager import defeats the lazy boundary for\nthat one step — it ships in the onboarding chunk regardless.\n====\n\nThere is no guard on any step. Navigation between them is by explicit\n`router.navigate()` calls in the components, and the happy path is a chain:\n\n[listing]\n----\nproduct-offering  --goNext-->  repayment-information  --goNext-->  sign-agreement  --> DocuSign\n       ^                              |                                   |\n       └──────── goBack ──────────────┘                    goBack ────────┘\n     goBack → /dashboard\n----\n\n`underwriting-review` sits outside that chain — the top bar links to it, and its\n`goBack()` returns to `/dashboard`.\n\n== `TopBarService`\n\nRoot-provided, and the single source of pre-activation state. It fetches on\nconstruction:\n\n[source,typescript]\n----\nconstructor(/* … */) {\n    this.fetchStatus();\n}\n\nfetchStatus(): void {\n    this.store.select(selectCurrentUserId).pipe(\n        take(1),\n        switchMap(supplierKey => this.onboardingService.getPreActivationStatus(supplierKey)),\n    ).subscribe(res => {\n        this.detailsSubject.next(res.details);\n        this.stepsSubject.next(this.prepareSteps(res.steps));\n    });\n}\n----\n\n[WARNING]\n.`fetchStatus()` depends on the user already being in the store\n====\n`selectCurrentUserId` is piped through `take(1)`, so it resolves with whatever the\nstore holds at that moment — including `undefined` before `userLoaded`. Because the\nservice is root-provided and calls `fetchStatus()` in its constructor, the outcome\ndepends on *when something first injects it*. Every step component and\n`BaseComponent` therefore call `fetchStatus()` again explicitly, which is why the\ncall appears scattered through the onboarding components rather than once.\n====\n\n=== Observables\n\n[cols=\"2,3\"]\n|===\n| `details$` | `TopBarDetails` — the whole pre-activation summary.\n| `steps$` | `OnbTopBarStep[]` — the stepper model.\n| `isSupplierIsInOnboarding$` | `showStatusBar && !isApplicationSubmitted`. Read by `AsideLeftComponent` to hide the support block.\n| `title$` / `description$` | Copy pushed by whichever step is active.\n|===\n\n[source,typescript]\n----\nexport interface TopBarDetails {\n    applicationSubmissionTs: string;\n    isActivated: boolean;\n    isApplicationSubmitted: boolean;\n    eligibleMarketplaces: string[];\n    showStatusBar: boolean;\n}\n----\n\n`isActivated` is what removes the Product Offerings menu entry and lets banners\nrender instead of the top bar. See\nxref:architecture/app-shell.adoc[Application Shell].\n\n=== Steps\n\nServer steps arrive as `PreActivationStatusDto[]` and are decorated with a\ntranslated title and a client-side link:\n\n[source,typescript]\n----\nprepareSteps(steps: PreActivationStatusDto[]): OnbTopBarStep[] {\n    return steps.map(step => ({\n        key: step.key,\n        title: this.getStepTitle(step.key),\n        status: step.status,\n        link: this.stepsLinks.get(step.key),\n    }));\n}\n----\n\n[cols=\"2,2,2\"]\n|===\n| Step key | Title key | Link\n\n| `yourDetails` | `YOUR_DETAILS` | `/manage-account`\n| `marketplacesAndPlatforms` | `MARKETPLACES_AND_PLATFORMS` | `/my-marketplaces`\n| `submitApplication` | `SUBMIT_APPLICATION` | `/onboarding/underwriting-review`\n| `submitted` | `SUBMITTED` | — (no link)\n| `underwritingReview` | `UNDERWRITING_REVIEW` | `/onboarding/underwriting-review`\n| `salesCallSummary` | `SALESCALLSUMMARY` | `/sales-call-summary`\n| `readyForActivation` | `READY_FOR_ACTIVATION` | — (no link)\n|===\n\nTitles resolve under `TOP_BAR_STATUS.STEPS.<key>`. `salesCallSummary` is a\n*client-invented* step — the type is widened for it:\n\n[source,typescript]\n----\nexport declare type ExtendedPreActivationStatusStepsKeys = PreActivationStatusStepsKeys | 'salesCallSummary';\n----\n\nThe links are hardcoded absolute paths, not `URLS_ROUTER` values — the one place\nin the app that bypasses that constant.\n\n=== Step status\n\n[source,typescript]\n----\nexport enum OnbProcessStepStatusEnum {\n    Completed        = 'Completed',\n    Skipped          = 'Skipped',\n    CompletedNow     = 'Complete Now',\n    InProgress       = 'In Progress',\n    Incomplete       = 'Incomplete',\n    NotYetReady      = 'Not Yet Ready',\n    NotQuiteThereYet = 'Not Quite There Yet',\n}\n----\n\nThe values are human-readable strings from the API, compared by equality. Note\n`CompletedNow = 'Complete Now'` — the member name reads as past tense but the\nvalue is an imperative call to action. `NotQuiteThereYet` is the soft-decline\nstatus.\n\n=== `submitApplication()`\n\nSubmitting the application is the only place the loading dialog is driven\nmanually:\n\n[source,typescript]\n----\nsubmitApplication(): Observable<void> {\n    const dialogRef = this.dialog.open<LoadingPageComponent, LoadingPageData>(LoadingPageComponent, {\n        disableClose: true,\n        data: { mode: 'determinate' },\n    });\n    let timerValue = 0;\n    const minInMs = 1000 * 60;\n    const intervalStep = (0.25 * minInMs) / 100;          // <1>\n\n    const timer = interval(intervalStep).subscribe(() => {\n        dialogRef.componentInstance.loadingProgressValue = timerValue++;\n    });\n\n    this.store.pipe(\n        select(selectCurrentUserId),\n        switchMap(supplierKey => this.onboardingService.submitApplication(supplierKey)),\n    ).subscribe(\n        () => { this.fetchStatus(); },\n        () => dialogRef.close(),\n    );\n\n    dialogRef.componentInstance.loaded.subscribe(() => {\n        dialogRef.close();\n        timer.unsubscribe();\n    });\n\n    return dialogRef.componentInstance.loaded;\n}\n----\n<1> 150 ms per percent — the bar is timed to fill in fifteen seconds regardless of\n    how long the request takes.\n\n[WARNING]\n.The progress bar is theatre, and it gates the close\n====\nThe dialog closes when the *bar* reaches 100, not when the request returns.\n`LoadingPageComponent` emits `loaded` from its setter:\n\n[source,typescript]\n----\n@Input()\nset loadingProgressValue(value: number) {\n    this.value = value;\n    if (value >= 100) {\n        this.progressBarMode = 'determinate';\n        this.loaded.emit();\n    }\n    this.cd.detectChanges();\n}\n----\n\nConsequences: a fast submission still shows fifteen seconds of progress; a\nsubmission slower than fifteen seconds closes the dialog while still in flight;\nand the returned `Observable<void>` signals \"the animation finished\", not \"the\napplication was submitted\". Only the error path closes early. The `select` here is\n*not* `take(1)`, so a later user-id emission re-submits.\n====\n\n== `product-offering` — `/onboarding/product-offering`\n\nWhere the supplier chooses which approved products to accept. The most rule-dense\nscreen in onboarding.\n\n[cols=\"2,3\"]\n|===\n| `product-offering.component.ts` | The page — loads offers, computes the title, validates the selection, accepts.\n| `product-offering.service.ts` | `getApprovedProducts()` and `acceptProducts()`.\n| `instant-access/`, `advance-line/`, `instant-advance/`, `access-plus/`, `new-free-access/` | One card per product.\n| `common-product-offer/` | Shared card shell.\n| `repayment-slider/` | Term/repayment-rate selector.\n| `item-with-label/` | Label + value row used inside the cards.\n| `wrappers/product-offering-wrapper-new/`, `…-old/` | Two page layouts, old and new.\n| `models/` | `ProductOffering{InstantAccess,Advance,AccessPlus}Class` plus a common base.\n|===\n\nThe DTOs are wrapped in classes on load, so the cards get behaviour (`showOffer`,\n`checked`, `setChecked()`) rather than raw data:\n\n[source,typescript]\n----\nconst instantAccess = new ProductOfferingInstantAccessClass(products?.instantAccess);\nconst advance       = new ProductOfferingAdvanceClass(products?.advance);\nconst accessPlus    = new ProductOfferingAccessPlusClass(products?.accessPlus);\n----\n\nThese three classes live in the *views* layer but are imported by\n`core/pyb/interfaces/approved-products.interface.ts` — see\nxref:architecture/conventions.adoc[Coding Conventions].\n\n=== Title selection\n\nThe heading depends on which combination was approved, via a ten-value enum:\n\n[source,typescript]\n----\nexport enum ProductOfferingTypeEnum {\n    ONLY_ACCESS_PLUS,\n    ONLY_INSTANT_ACCESS,\n    ONLY_FREE_ACCESS,\n    ONLY_INSTANT_ADVANCE,\n    ONLY_ADVANCE_LINE,\n    INSTANT_ACCESS_AND_ADVANCE_LINE,\n    INSTANT_ACCESS_AND_INSTANT_ADVANCE,\n    FREE_ACCESS_AND_INSTANT_ADVANCE,\n    FREE_ACCESS_AND_ADVANCE_LINE,\n    NONE,\n}\n----\n\n`getProductConfigurationType()` resolves it from three inputs — whether both\nproducts are offered, whether the supplier is free-access (`selectIsFreeAccess`),\nand whether the advance product is an Advance Line or an Instant Advance\n(`advance.isAdvanceLineAvailable`). `productPageTitles` maps the enum to a\ntranslation key, interpolated with the supplier's first name.\n\n`ONLY_ACCESS_PLUS` has a title key in `productPageTitles` but is never returned by\n`getProductConfigurationType()` — Access Plus is always offered alongside Instant\nAccess, so the copy is unreachable. `productPageTitles` is typed\n`Record<ProductOfferingTypeEnum, string>`, so the compiler requires the entry to\nexist.\n\n=== Card configuration\n\n`ProductOfferingService` composes each card's config from a static base in\n`product-offering.const.ts` plus per-supplier values off the product class:\n\n[source,typescript]\n----\ngetAdvanceLineConfig(product, isCheckable, initChecked, newVersion): CommonProductOfferConfig {\n    return {\n        ...advanceLineConfig,                                                     // <1>\n        expirationDate: product.advanceDetails.instantAdvanceTerms.expirationDate,\n        expirationDays: product.expirationDays,\n        isOfferActive: product.isOfferActive,\n        checkDisabled: product.checkDisabled,\n        showExpirationInfo: product.showExpirationInfo,\n        items: this.getAdvanceLineItems(product, newVersion),                     // <2>\n        isCheckable,\n        initChecked,\n    };\n}\n----\n<1> Logo class, header key and description key.\n<2> The row list, built by a factory.\n\nEvery card body is a `CommonProductOfferItem[][]` — an array of rows per\nmarketplace — produced by a factory in `product-offering.const.ts`. Each factory\ncomes in an old and a `…NewFactory` variant, selected by the `newVersion` flag that\nalso picks between `product-offering-wrapper-old` and `…-new`:\n\n[cols=\"2,3\"]\n|===\n| `instantAccessItemsFactory` / `…NewFactory` | Old: per-marketplace advance rate, fee, transfer fee and icon. New: three translated sentences using `overallRate` / `overallFee`.\n| `instantAdvanceItemsFactory` / `…NewFactory` | Approved amount, weekly cost, expected payback in weeks, marketplace icons.\n| `advanceLineItemsFactory` / `…NewFactory` | Approved limit, weekly fee percent, marketplace icons.\n| `accessPlusItemsFactory` | Funding info, advance rate, and the discount fee from `marketplace.templates[0].discount_fee_percent`.\n| `repaymentSliderItemsFactory` | Advance amount, payback after rebate, rebate, total cost, weekly cost — all currency-formatted, each with a tooltip from `AdvanceLineTooltipTextService`.\n|===\n\n[NOTE]\n.Two rough edges in this layer\n====\n`advanceLineItemsFactory` and `advanceLineItemsNewFactory` have identical bodies,\nso the `newVersion` flag makes no difference for Advance Line.\n`getAccessPlusItems(product, newVersion)` accepts `newVersion` and ignores it.\n\nThe old Instant Access factory shows a fee row when `!!marketplace?.fee ||\nisFreeAcces` — a `0` fee is falsy, so a genuinely zero fee is hidden unless the\nsupplier is free-access. That interacts with the zero-fee card selection below.\n====\n\n=== Data access\n\n`ProductOfferingService` bypasses `@payability/pyb-ng2-api` and calls `HttpClient`\ndirectly:\n\n[source,typescript]\n----\ngetApprovedProducts(): Observable<ApprovedProducts> {\n    return this.httpClient.get<ApprovedProducts>(`${this.env.apiUrl}/suppliers/${this.user.getCurrentUserId()}/approved_products`);\n}\n\nacceptProducts(products: AcceptProductDto): Observable<any> {\n    return this.httpClient.post<AcceptProductDto>(`${this.env.apiUrl}/suppliers/${this.user.getCurrentUserId()}/accept_products`, products);\n}\n----\n\nBoth still pass through the interceptor chain, so auth and error handling are\nunaffected — but the URLs are assembled here rather than in the generated client.\nSee xref:integrations/api-client.adoc[API Client].\n\n=== Selection rules\n\nAccess Plus and Instant Access are mutually exclusive, enforced by unchecking the\nother:\n\n[source,typescript]\n----\nprivate accessPlusValidation(productName: string | undefined): void {\n    if (productName === undefined) { return; }\n    if (productName === 'accessPlus' && this.approvedProducts.accessPlus.checked) {\n        this.approvedProducts.instantAccess.setChecked(false);\n    }\n    if (productName === 'instantAccess' && this.approvedProducts.instantAccess.checked) {\n        this.approvedProducts.accessPlus.setChecked(false);\n    }\n}\n----\n\nThe Next button requires a selection — but only when more than one product is on\noffer:\n\n[source,typescript]\n----\nthis.disabledNextBtn =\n    !(this.bothProductsAvailable\n        ? instantAccess?.checked || advance?.checked || accessPlus?.checked\n        : instantAccess?.checked || accessPlus?.checked) ||\n    this.noProducts ||\n    this.loading;\n----\n\nWith a single advance-only offer, `advance.checked` is *not* in the second branch,\nso the button stays disabled — that path relies on the offer being auto-accepted\nrather than ticked.\n\nAcceptance mirrors the same asymmetry: with both products offered, the supplier's\ntick decides; with one, `showOffer` decides.\n\n[source,typescript]\n----\nconst acceptProducts: AcceptProductDto = {\n    advance:       this.bothProductsAvailable ? advance?.checked       : advance?.showOffer,\n    instantAccess: this.bothProductsAvailable ? instantAccess?.checked : instantAccess?.showOffer,\n    accessPlus:    this.approvedProducts.accessPlus?.showOffer ? accessPlus?.checked : false,\n};\n\nif (acceptProducts.accessPlus === true) {\n    acceptProducts.instantAccess = true;      // <1>\n}\n----\n<1> Access Plus implies Instant Access. Even though the two are mutually exclusive\n    *in the UI*, accepting Access Plus sends both — Access Plus is a tier on top of\n    Instant Access, not an alternative to it.\n\n=== The zero-fee special case\n\n[source,typescript]\n----\nconst totalFee = sum(instantAccess?.marketplaces.map(mkt => mkt.fee));\nthis.showNewFreeAccess = instantAccess?.showOffer && totalFee === 0 && !advance?.showOffer && !accessPlus?.showOffer;\n----\n\nA supplier offered only Instant Access, at zero fee across every marketplace, gets\nthe `new-free-access` card instead of the standard one.\n\n[NOTE]\n====\n`loadProducts()` contains a `console.log(instantAccess, advance, accessPlus)` that\nprints the full offer set — including rates and fees — to the browser console on\nevery visit.\n====\n\n== `repayment-information` — `/onboarding/repayment-information`\n\nExplains how repayment will work. It picks one of two `TemplateRef` s based on\nwhether the supplier has an autodebit payment configuration:\n\n[source,typescript]\n----\nprivate handlePayment(autodebit: AutodebitPaymentConfigInterface): void {\n    this.autodebitPCI = autodebit;\n    this.activeRepayment = autodebit ? this.autodebit : this.fof;\n    this.loading = false;\n    this.cd.detectChanges();\n}\n----\n\n`autodebitPmtCfg` present → the autodebit template; absent → the *FOF*\n(first-out-of-funds) template, which repays from marketplace payouts. The marketplace\nnames for the FOF copy come from `PaymentFlowsService.getPaymentFlows()`:\n\n[source,typescript]\n----\nthis.marketplaces.fof.marketplaceName = res.fof.map(paymentFlow => paymentFlow.marketplaceName).join(', ');\n----\n\nThe initial value is the literal string `'...'`, which is what renders until the\ncall returns.\n\nStatus is read with `filter(Boolean), take(1)` in `ngAfterViewInit` — after the\nview exists, because the template refs must be resolvable before one is chosen.\n\n== `sign-agreement` — `/onboarding/sign-agreement`\n\nThe last step. It hands off to DocuSign with the `OnboardingActivation` flow:\n\n[source,typescript]\n----\nprivate handleDocusign(userId: string): void {\n    const redirectUrl = this.docusignVerificationService.generateRedirectUrl({\n        flowType: DocusignFlowEnum.OnboardingActivation,\n    });\n\n    this.docusignService.getOnboardingActivationDocuSignUrl(userId, redirectUrl).subscribe(response => {\n        this.docusignVerificationService.redirectToDocusign(response.url);\n        this.topBarService.fetchStatus();\n    });\n}\n----\n\n`redirectToDocusign()` is what sets the `docusignFlowInitialized` replay flag, so\nthis must never be replaced with a direct `window.location` assignment. The full\nround trip is in xref:business/docusign.adoc[DocuSign Contract Flows].\n\nA local `loading` flag guards against a double click; it is never reset, which is\ncorrect here because the page is leaving for DocuSign.\n\n== `underwriting-review` — `/onboarding/underwriting-review`\n\nA status page with exactly two states, derived from the stepper rather than from\nthe user:\n\n[source,typescript]\n----\nprivate handleUnderwriting(steps: OnbTopBarStep[]): void {\n    const underwritingStep = steps?.find(step => step?.key === TopBarAvailableSteps.UnderwritingReview);\n    const isNotYetReady = OnbProcessStepStatusEnum.NotQuiteThereYet === underwritingStep.status;\n\n    this.status = isNotYetReady ? 'DECLINED' : 'PENDING';\n    this.updateTitle();\n    this.updateStatus();\n    this.cd.detectChanges();\n}\n----\n\n`'Not Quite There Yet'` → `DECLINED` with warning colours; anything else →\n`PENDING` with primary colours. Copy comes from\n`FEATURES.ONBOARDING.UNDERWRITING_REVIEW.{PENDING|DECLINED}.TITLE`.\n\n[WARNING]\n====\n`underwritingStep.status` is read without a null check. If the API omits the\n`underwritingReview` step — which it does once the supplier moves past it — `find`\nreturns `undefined` and the component throws. Reaching this route after activation\nis therefore not safe, and nothing guards it.\n====\n\nThe page emits `OnboardingEventType.LoadedApplicationStatus` on view init. See\nxref:analytics/event-catalog.adoc[Event Catalog].\n\n== `loading-page`\n\nA dialog, not a route, despite the name. Used by `submitApplication()` and by the\nactivation flow.\n\n[cols=\"2,3\"]\n|===\n| `mode` | `'LOADING_PAGE'` by default; `'ACTIVATING_ACCOUNT'` when `data.activatingAccount` is set. Selects the copy namespace.\n| `progressBarMode` | `'indeterminate'` by default; `'determinate'` when the caller drives progress.\n| `loadingProgressValue` | Setter input. Emits `loaded` at ≥ 100.\n| `loaded` | Output — the \"animation complete\" signal callers await.\n|===\n\n`MAT_DIALOG_DATA` is injected `@Optional()`, so the component also works as a plain\ninline element with `[loadingProgressValue]` bound.\n\n== Related pages\n\n* xref:business/onboarding.adoc[Onboarding] — schemas, compliance, which products unlock when\n* xref:business/docusign.adoc[DocuSign Contract Flows] — the activation signature\n* xref:features/product-offerings.adoc[Product Offerings] — the post-activation cross-sell page, a different feature\n* xref:architecture/app-shell.adoc[Application Shell] — where the top bar renders\n* xref:features/manage-account.adoc[Manage Account] — the `yourDetails` step target\n"},{"component":"supplier-client","module":"ROOT","path":"features/dashboard.adoc","title":"Dashboard","description":"The landing page — which widgets appear, what gates them, and what the page loads.","url":"https://docx.payability.com/supplier-client/features/dashboard.html","navOrder":36,"source":"= Dashboard\n:description: The landing page — which widgets appear, what gates them, and what the page loads.\n\n`/dashboard` — `DashboardModule`, guarded by `DocuSignGuard` (which is really the\nDocuSign *return handler*, see xref:routing/guards.adoc[Route Guards]).\n\nThe dashboard is a widget board. Which cards appear depends entirely on the\nsupplier's calculated permissions and product state, so two suppliers rarely see\nthe same page.\n\n== Widgets and their gates\n\n[cols=\"2,2,3\"]\n|===\n| Widget | Permission | Shows\n\n| `pyb-your-account-widget` | — | Account status and activation progress.\n| `pyb-your-identity-widget` | — | KYC status and the ID-upload prompt.\n| `pyb-payments-config-widget` | `TRANSFER_INFORMATION` | The configured payout method.\n| `pyb-advance-line-widget` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_WIDGET` | Available credit, outstanding balance, draw CTA.\n| `pyb-advance-line-open-advances` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_REPORT` | Open drawdowns.\n| `pyb-instant-advance-widget` | `INSTANT_ADVANCE_REPORT_WIDGET` | Active Instant Advance and repayment progress.\n| `pyb-instant-advance-pending-widget` | `INSTANT_ADVANCE_PENDING` | Pending Instant Advance awaiting signature.\n| `pyb-instant-access-widget` | — | Instant Transfer eligibility CTA.\n| `pyb-seller-card-widget` | `SELLER_CARD` | Card balance and quick actions.\n| `pyb-vendorpay-incoming-payments-widget` | `VENDOR_PAY` | Incoming vendor payments.\n| `pyb-referral-banner` | `REFERRAL` | Referral programme promotion.\n| `daily-summary` | `DAILY_SUMMARY` | Recent daily transaction totals.\n| `additional-documents-widget` | — | Outstanding document requirements.\n| CTA cards | `VIEW_NOTIFICATIONS` | Notification-driven offers — see xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n|===\n\nGating in the template uses the *full dotted* permission names:\n\n[source,html]\n----\n<div *ngxPermissionsOnly=\"['ADVANCE_LINE.VIEW_ADVANCE_LINE_WIDGET']\">\n    <pyb-advance-line-widget></pyb-advance-line-widget>\n</div>\n----\n\nWidgets render `pyb-skeleton` placeholders while their slice is loading.\n\n== `DashboardComponent`\n\n`views/pages/dashboard/dashboard.component.ts`, extends `Subscriber`.\n\nState it selects:\n\n[cols=\"2,3\"]\n|===\n| `selectCurrentUser` | The user, for status-driven rendering.\n| `selectAdvanceLineStatus` / `selectAdvanceLineStatusType` | Advance Line summary.\n| `selectActiveInstantAdv` | The active Instant Advance.\n| `selectAllEntitiesSupplierMarketplaces` | Connected channels.\n| `selectBalanceState` | Available balance.\n| `selectIsFreeAccess` | Free-access cohort rendering.\n|===\n\nActions it dispatches on init:\n\n[cols=\"2,3\"]\n|===\n| `fetchDashboardNotifications()` | Loads the notification set that drives CTA cards.\n| `loadSummary()` | Loads the Advance Line summary.\n|===\n\nServices it composes:\n\n[cols=\"2,3\"]\n|===\n| `SubheaderService` | Page title and breadcrumbs.\n| `TopBarService` | The onboarding progress bar, shown when the supplier is still pre-activation (`isTopBarVisible`).\n| `ProductSummaryService` / `ProductSummaryGridService` | The product summary panel.\n| `InternalMessagesService` | Cross-component messages and the account-cancelled banner.\n| `PybToastService` | Inline success and error toasts.\n| `SuppliersService` | Direct status reads where the slice is not sufficient.\n|===\n\n[NOTE]\n====\n`DashboardComponent` does *not* set `changeDetection: OnPush` — it is one of the\nfew components using default change detection, because it aggregates many\nindependently updating sources.\n====\n\n== Page-load timing\n\n`/dashboard` is the only route in the `PageLoadTimeTrackerModule.forRoot([…])`\nallow-list, so its render duration is measured and pushed to CloudWatch. See\nxref:integrations/observability.adoc[Observability].\n\n== Session-start side effects\n\nLanding on the dashboard is what triggers most one-time session behaviour, via\n`GlobalSubscriptionsService.init()`:\n\n* welcome modal (`new_sd_welcome`),\n* Airwallex terms modal (`airwallex_pcard_terms`),\n* Seller Card transition modal (`identity_document_expired`),\n* the re-sign contract flow,\n* the 2FA setup banner, suspension banner, cancellation banners,\n* marketplace connector health checks,\n* `fetchFees()`.\n\n`SuspensionReasonInitializerService` additionally listens for navigation to\n`/dashboard` and opens the suspension modal there specifically — see\nxref:business/suspension.adoc[Suspension].\n\n== DocuSign returns\n\nBecause `DocuSignGuard` is applied here, a supplier returning from DocuSign with\n`flowType` and `event` query parameters has their flow executed *before* the\ndashboard renders, and is then redirected to a clean URL. See\nxref:business/docusign.adoc[DocuSign Contract Flows].\n"},{"component":"supplier-client","module":"ROOT","path":"features/manage-account.adoc","title":"Manage Account","description":"Account settings — personal and business information, transfer setup, security, and the cancellation workflow.","url":"https://docx.payability.com/supplier-client/features/manage-account.html","navOrder":37,"source":"= Manage Account\n:description: Account settings — personal and business information, transfer setup, security, and the cancellation workflow.\n\n`/manage-account` — `ManageAccountModule`, rendered inside `ManageAccountComponent`\nwith a tab bar.\n\n== 2FA enforcement\n\nThe parent route applies:\n\n[source,typescript]\n----\nconst force2FAConfirmationGuard = {\n    canActivate: [TwoFactorAuthenticationGuard],\n    data: { force2FAConfirmation: true },\n};\n----\n\n[IMPORTANT]\n====\n`force2FAConfirmation: true` means the supplier is re-challenged *every time*\nthey enter account settings, even if they already passed a challenge earlier in\nthe session. Passing `?skipTwoFactorCheck=…` bypasses it — that escape hatch\nexists for internal flows and is worth knowing when a bug report says \"it did not\nask me for a code\".\n====\n\n== Tabs\n\nRoute strings and labels come from `manage-account.config.ts`:\n\n[source,typescript]\n----\nexport const manageAccountRoutes: RoutesConfig = {\n    yourInformation: {\n        route: 'your-information',\n        label: 'SETTINGS.MANAGE_ACCOUNT.YOUR_INFORMATION.TAB_NAME',\n        children: {\n            uploadId: { route: 'upload-id', label: 'SETTINGS.MANAGE_ACCOUNT.YOUR_INFORMATION.UPLOAD_ID_WIDGET.NAME' },\n        },\n    },\n    businessInformation: { route: 'business-information', label: 'SETTINGS.MANAGE_ACCOUNT.BUSINESS_INFORMATION.TAB_NAME' },\n    transferInformation: { route: 'transfer-information', label: 'SETTINGS.MANAGE_ACCOUNT.TRANSFER_INFORMATION.TAB_NAME' },\n    security: {\n        route: 'security',\n        label: 'SETTINGS.MANAGE_ACCOUNT.SECURITY.TAB_NAME',\n        children: {\n            changePassword: { route: 'change-password', label: 'SETTINGS.MANAGE_ACCOUNT.SECURITY.CHANGE_PASSWORD.NAME' },\n            twoFactor:      { route: 'two-factor',      label: 'SETTINGS.MANAGE_ACCOUNT.SECURITY.TWO_FACTOR' },\n        },\n    },\n} as const;\n----\n\n[cols=\"2,2,3\"]\n|===\n| Path | Module | Contents\n\n| `` (empty) → `your-information` | `YourInformationModule` | Default tab.\n| `your-information` | `YourInformationModule` | `your-personal-information-widget`, `your-address-widget`.\n| `your-information/upload-id` | `UploadIdModule` | Government ID upload for KYC.\n| `business-information` | `BusinessInformationModule` | Legal entity details.\n| `transfer-information` | `TransferInformationModule` (reused from `pages/settings/`) | Payout configuration.\n| `security` | `SecurityModule` | Password and 2FA hub.\n| `security/change-password` | `ChangePasswordModule` | Password change.\n| `security/two-factor` | `TwoFactorModule` | 2FA enrolment.\n| `two-factor/confirm/:emailcode` | `ManageAccountComponent` (sibling route) | Email confirmation landing.\n|===\n\n`TransferInformationModule` is shared with the `pages/settings/` tree, which is\nwhy it is imported from outside the `manage-account` folder.\n\n== Your Information\n\nBacked by the `personalInformation` slice\n(`updatePersonalInformation` → `updatedPersonalInformation`).\n\n[cols=\"2,3\"]\n|===\n| `your-personal-information-widget` | Name, date of birth, phone, email, tax id.\n| `your-address-widget` | Residential address, built on the shared `simple-address-form` template.\n|===\n\nThe residential address is one half of the Seller Card US-residency test — see\nxref:business/seller-card.adoc[Seller Card].\n\n=== Upload ID\n\n`UploadIdModule` with its own `UploadIdService`. Uses\n`components/image-upload/` and `components/image-preview/`, with\n`FileHelperService` handling base64 conversion and type/size validation.\n\nIdentity *re-verification* (as opposed to first-time upload) goes through Entrust\n— see xref:integrations/entrust.adoc[Entrust Identity].\n\n== Business Information\n\nBacked by the `businessInformation` slice. Collects legal name, business type\ncode, incorporation state, `incorporatedInTheUS`, owner SSN, EIN (`taxId`),\nestablished date and the business address.\n\nThe business address is the other half of the Seller Card US-residency test, and\nthe address shown in Instant Transfer AVS failure dialogs.\n\n== Transfer Information\n\nPayout configuration, backed by the `paymentMethods` slice. Covers bank accounts\n(via Plaid), wire details and the Instant Transfer card. Requires the\n`TRANSFER_INFORMATION` permission, which pending suppliers only receive once\n`pmtCfgProvided` is set.\n\nRelated modals: `modals/transfer-information/`, `modals/switch-cd-dialog/`,\n`modals/bank-verification-info-modal/`, `modals/lost-bank-connection-modal/`.\n\n== Security\n\n[cols=\"2,3\"]\n|===\n| `security/` | Hub page listing `password-widget` and `two-factor-widget`.\n| `change-password/` | Password change form.\n| `two-factor/` | 2FA enrolment via `two-factor-setup-widget`.\n|===\n\nPassword rules are enforced with validators from `core/pyb/validators/`. A `400`\nfrom `/execute_password_change` shows the *server's* message rather than the\ngeneric error toast — see xref:security/interceptors.adoc[HTTP Interceptors].\n\n== Cancellation workflow\n\n`manage-account/cancellation-workflow/` renders the deactivate or reactivate\naction, driven by `CancellationWorkflowService` and\n`CancellationWorkflowOptionAvailableEnum`. Full detail:\nxref:business/cancellation.adoc[Cancellation & Reactivation].\n\n== Documentation\n\n`manage-account/documentation/` lists documents Payability holds and any\noutstanding requirements, backed by `DoctypeComplianceEnum.Documentation` and the\n`additional-documents-widget`.\n\n== Services\n\n[cols=\"2,3\"]\n|===\n| `ManageAccountService` | Feature-scoped, provided by `ManageAccountModule`. Tab state and shared page behaviour.\n| `UploadIdService` | ID upload.\n| `CancellationWorkflowService` | Root-provided; deactivation and reactivation.\n| `FormHelpersService` | Shared reactive-form helpers.\n| `FileHelperService` | File reading and validation.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"features/reports.adoc","title":"Reports","description":"The reporting suite — every report, the shared AG Grid infrastructure, filtering, pagination and export.","url":"https://docx.payability.com/supplier-client/features/reports.html","navOrder":38,"source":"= Reports\n:description: The reporting suite — every report, the shared AG Grid infrastructure, filtering, pagination and export.\n\n`/reports` — `ReportsModule`, rendered inside the `ReportsComponent` shell.\nReports are the densest part of the application and share a substantial amount of\ninfrastructure.\n\n== Report index\n\n[cols=\"2,2,3\"]\n|===\n| Route | Module | Contents\n\n| `/reports/transfer` | `TransferReportPageModule` | Fund transfers to the supplier.\n| `/reports/card-transactions` | `CardTransactionsPageComponent` (eager) | Seller Card transactions.\n| `/reports/fee-report` | `FeeReportPageModule` | Fee breakdown by type and date.\n| `/reports/outstanding-balance` | `OutstandingBalancePageModule` | Outstanding repayment balances.\n| `/reports/instant-advance-report` | `InstantAdvanceReportPageModule` | Instant Advance history and repayment.\n| `/reports/daily-summary-report` | `DailySummaryModule` | Daily revenue, repayment and net transfer.\n| `/reports/marketplace-payment-report` | `MarketplacePaymentPageModule` | Payments received per marketplace.\n| `/reports/marketplace-transaction-details` | `MarketplaceTransactionPageModule` | Transaction-level marketplace detail.\n| `/reports/reconciliation-view` | `ReconciliationViewReportModule` | Reconciliation between marketplace and Payability records.\n| `/reports/vendor-pay-report` | `VendorpayReportsModule` | Outgoing vendor payments.\n| `/reports/contracts-overview-report` | `ContractsOverviewPageModule` | Long Term Contract history.\n| `/daily-summary-report` (top level) | `DailySummaryModule` | Same report, gated by `NgxPermissionsGuard` on `REPORTS.DAILY_SUMMARY`.\n|===\n\n[NOTE]\n====\n`instant-advance-report` is declared *twice* in `reports-routing.module.ts` —\nonce near the top and once at the bottom, both pointing at\n`InstantAdvanceReportPageModule`. The second is unreachable.\n====\n\n== Permissions\n\n[cols=\"2,3\"]\n|===\n| `REPORTS` | Granted when `hasPaymentHistory` **and** not onboarding. Gates the menu entry.\n| `REPORTS.DAILY_SUMMARY` | Same condition; enforced as a route guard on the top-level daily-summary route.\n| `REPORTS.INSTANT_ADVANCE_REPORT` / `.INSTANT_ADVANCE_REPORT_MVP` | Granted when `hasInstantAdvHistory`.\n| `REPORTS.VIEW_INSTANT_ADVANCE_REPORT_MVP_ELEMENT` | Declared for element-level gating; not currently granted.\n|===\n\nIndividual report routes are *not* individually guarded — the gating is on the\nmenu and on the permission-driven data availability.\n\n== `ReportBase`\n\n`views/pages/reports/report-base.class.ts` is the abstract base every report page\nextends. It extends `Subscriber` and owns the AG Grid lifecycle.\n\n[source,typescript]\n----\nexport abstract class ReportBase extends Subscriber implements AfterViewInit {\n    gridApi: GridApi;\n    dataSource: BaseDataSource;\n    viewInit$   = this.viewInitSubject.asObservable();\n    clickClearListener$: Observable<void>;\n    hasFilter: boolean;\n    hasItems: boolean;\n    hasError: boolean;\n    registeredActions = new Map<ReportActionEnum, ReportAction>();\n\n    abstract isDesktopReportVisible(): boolean;\n    abstract get pageSize(): number;\n    abstract get pageIndex(): number;\n    abstract get filterConfiguration();\n\n    exportCSV(): void  { throw new NotImplementedError('exportCSV'); }\n    applyFilter(): void { throw new NotImplementedError('applyFilter'); }\n    clearFilter(): void { throw new NotImplementedError('clearFilter'); }\n\n    registerAction(actionType: ReportActionEnum, action: (param?: any) => void, param?: any) {\n        this.registeredActions.set(actionType, new ReportAction(actionType, action, param));\n    }\n}\n----\n\n[NOTE]\n====\n`exportCSV`, `applyFilter` and `clearFilter` throw `NotImplementedError` rather\nthan being abstract, so a report only implements what it actually offers. A\nreport that exposes an export button without overriding `exportCSV()` throws at\nruntime, not compile time.\n====\n\n`isDesktopReportVisible()` is the responsive switch — below the breakpoint the\ngrid is replaced by `ReportsMobileContentRowComponent`, which renders each row as\na stacked label/value card.\n\nActions are registered rather than hardcoded:\n\n[source,typescript]\n----\nexport enum ReportActionEnum { ExportAll, ExportCSV }\n----\n\n== Data sources\n\n`BaseDataSource` (`core/pyb/models/base/data-source/`) adapts a paginated API to\nAG Grid's server-side model, using `QueryParamsModel` / `QueryResultsModel` from\n`core/_base/crud`.\n\nPage sizes are fixed:\n\n[source,typescript]\n----\nexport enum ReportsPageSizes {\n    Desktop            = 50,\n    Mobile             = 6,\n    DailySummaryMobile = 4,\n}\n\nexport enum ReportsFilter {\n    DateFormat = 'yyyy-MM-dd',\n}\n----\n\n== Grid infrastructure\n\nTwo layers cooperate.\n\n=== `core/pyb/components/grid/`\n\n`PybGridModule` — the application's grid wrapper, imported by `AppModule`.\n\n[cols=\"2,3\"]\n|===\n| `grid.component.ts` | The wrapper component and `GridOptions` handling.\n| `cells/currency/` | `CurrencyCellComponent` — currency formatting.\n| `cells/date/` | `DateCellComponent` — locale-aware dates.\n| `cells/translate/` | `TranslateCellComponent` — routes cell values through `@ngx-translate`.\n| `cells/progress-bar/` | `ProgressBarCellComponent` — inline repayment progress.\n| `cells/actions/` | `ActionsCellComponent` — per-row buttons.\n| `cells/toggle/` | `ToggleCellComponent` — row-level switches.\n| `cells/cell/` | `CellComponent` — the generic base.\n| `cells/base-cell.component.ts` | Shared cell behaviour.\n| `filter-popup/` | Column-header filter overlay.\n|===\n\n=== `core/reports/`\n\n`PybNg2ReportsLibModule` — lower-level report infrastructure.\n\n[cols=\"2,3\"]\n|===\n| `ag-grid/cells/`, `headers/`, `row/` | Renderers and header components.\n| `ag-grid/overlay/` | No-rows and loading overlays.\n| `ag-grid/status-bar/` | Grid status bar.\n| `ag-grid/value-formatters/` | Shared value formatters.\n| `ag-grid/styles/`, `styles/` | Grid theming.\n| `daily-summary-report/`, `fee-report/` | Report-specific helpers.\n| `tokens/base-url.token.ts` | Base URL injection for report data services.\n|===\n\n`PybPaginationModule` (`core/pyb/components/pagination/`) provides the pagination\ncontrol below each grid.\n\nAG Grid Enterprise is licensed at bootstrap through\n`initializeAgGridLicenseProvider` — see xref:architecture/bootstrap.adoc[Bootstrap].\n\n== Column configuration by DI token\n\nColumn sets are provided rather than hardcoded, so one grid component serves\nseveral reports. `ReportsModule` provides:\n\n[cols=\"2,3\"]\n|===\n| `FEE_REPORT_COLUMNS_CONFIG_TOKEN` | `feeReportColumnsConfig`\n| `FEE_REPORT_AUTO_COLUMN_GROUP_CONFIG_TOKEN` | `feeReportAutoColumnGroupConfig`\n| `OUTSTANDING_BALANCE_COLUMNS_CONFIG_TOKEN` | `outstandingBalanceColumnsConfig`\n| `OUTSTANDING_BALANCE_AUTO_COLUMN_GROUP_CONFIG_TOKEN` | `outstandingBalanceAutoColumnGroupConfig`\n| `VENDOR_PAY_INCOMING_REPORT_COLUMNS_CONFIG_TOKEN` | `vendorPayIncomingColConfig`\n| `VENDOR_PAY_OUTGOING_REPORT_COLUMNS_CONFIG_TOKEN` | `vendorPayOutgoingColConfig`\n| `VENDOR_PAY_REPORT_AUTO_COLUMNS_CONFIG_TOKEN` | `vendorPayReportAutoColumnGroupConfig`\n|===\n\nThe incoming and outgoing Vendor Pay reports are the clearest example — identical\ncomponent, different injected column set.\n\n== Export\n\n`ExportService` (`core/pyb/services/export-service.ts`) opens dialogs; it does not\ngenerate files itself.\n\n[source,typescript]\n----\nexport(title = 'Export', description: string, waitDesciption = 'Your report is preparing', loading$: Observable<boolean>) {\n    return this.dialog.open<ExportDialogModal>(ExportDialogModal, {\n        data: { title, description, waitDesciption, loading$ },\n        width: DIALOG_SIZE.SMALL.width,\n    });\n}\n\nexportModal(): PybDialogRef<ExportFormDialogComponent> {\n    return this.dialog.open<ExportFormDialogComponent>(ExportFormDialogComponent, { width: DIALOG_SIZE.SMALL.width });\n}\n----\n\n[cols=\"2,3\"]\n|===\n| `ExportDialogModal` | Progress dialog driven by the caller's `loading$`.\n| `ExportFormDialogComponent` | Extended dialog with date range and filter options.\n|===\n\n[NOTE]\n====\nThe default strings (`'Export'`, `'Your report is preparing'`) are English\nliterals in the service signature. Callers should pass translated values.\n====\n\nThe report itself performs the download; the dialog only reports progress.\n\n== Daily Summary\n\nThe only report with its own NgRX entity slice\n(`reports.dailySummaryReports`, keyed by `xactPostDate`) — see\nxref:state/entity-slices.adoc[Entity Slices]. It uses class-based actions and\ncarries the `QueryParamsModel` page descriptor alongside the data.\n\nLoad failures are counted as a CloudWatch metric through\n`initializeDailySummaryLoadDataErrorTrackerProvider`, so a broken data pipeline\nraises an operational alarm rather than only a UI error.\n\nA condensed version appears on the dashboard as the `daily-summary` widget.\n\n== Supplier key provider\n\n`views/pages/reports/supplier-key-provider.service.ts` supplies the supplier key\nto report data services, so report components do not each select it from the\nstore.\n"},{"component":"supplier-client","module":"ROOT","path":"features/seller-card-pages.adoc","title":"Seller Card Pages","description":"The /seller-card list page and the /newcard ordering funnel.","url":"https://docx.payability.com/supplier-client/features/seller-card-pages.html","navOrder":39,"source":"= Seller Card Pages\n:description: The /seller-card list page and the /newcard ordering funnel.\n\nTwo routes serve the card product. Which one a supplier lands on is decided by\n`SellerCardListGuard`.\n\n== Routing decision\n\n[source,typescript]\n----\ncanActivate(): Observable<boolean | UrlTree> {\n    this.store.dispatch(loadPaymentMethods());\n    this.store.dispatch(loadAirwallexCards());\n\n    return combineLatest([\n        this.store.pipe(select(selectIsPaymentMethodsLoaded)),\n        this.store.pipe(select(selectPaymentsMethodLoading)),\n        this.store.pipe(select(selectAirwallexCardsLoaded)),\n    ]).pipe(\n        filter(([loaded, loading, awxLoaded]) => loaded && !loading && awxLoaded),   // <1>\n        take(1),\n        withLatestFrom(\n            this.store.pipe(select(selectPcardsPaymentMethods)),\n            this.store.pipe(select(selectHasAirwallexCards)),\n        ),\n        map(([_, methods, hasAirwallexCards]) => {\n            const hasPcards = isArray(methods) && !isEmpty(methods);\n            if (hasPcards || hasAirwallexCards) { return true; }\n            return this.router.parseUrl('newcard');                                  // <2>\n        }),\n    );\n}\n----\n<1> The guard waits until *both* card sources have settled, so it never routes on\n    a half-loaded picture.\n<2> No cards from either issuer → the ordering funnel.\n\n[listing]\n----\n/seller-card\n    ├─ has Marqeta p-cards or Airwallex cards ──▶ SellerCardListComponent\n    └─ has neither ─────────────────────────────▶ redirect to /newcard\n----\n\n== `/seller-card` — the list page\n\n`SellerCardModule` → `SellerCardPage` → `SellerCardListComponent`.\n\nImports and composition:\n\n[cols=\"2,3\"]\n|===\n| `SellerCardsModule` | The `seller-cards` widget.\n| `SellerCardsPanelModule` | The unified \"Your Cards\" panel rendering both issuers.\n| `CardTransactionsPageModule.forRoot({ noRowsMessage: 'SELLER_CARD.GRID_NO_ROWS_TEXT' })` | Embedded transaction grid with card-specific empty-state copy.\n| `ReportsCommonModule` | Shared report scaffolding.\n| `IconListModule`, `DynamicFieldModule`, `PybSkeletonModule` | Presentation.\n|===\n\nCard operations are routed by provider. `UnifiedSupplierCard.raw` carries the\noriginal provider model, so the page dispatches to the Marqeta or Airwallex\nservice accordingly:\n\n[cols=\"2,2,2\"]\n|===\n| Action | Marqeta | Airwallex\n\n| Block | `SellerCardBlockService` | `AirwallexCardBlockService`\n| Unblock | `SellerCardUnblockService` | `AirwallexCardUnblockService`\n| View details | `SellerCardViewService` | `AirwallexCardViewService`\n| Activate | `SellerCardActivateService` | `AirwallexCardService.activateCard()`\n|===\n\nEvery one of them requires a passed 2FA challenge — see\nxref:business/seller-card.adoc[Seller Card].\n\n=== Guards in this folder\n\n[cols=\"2,3\"]\n|===\n| `SellerCardListGuard` | Routes to `/newcard` when the supplier holds no cards.\n| `ActiveUserGuard` | Redirects to `/newcard` when `supplierStatus !== Active`.\n|===\n\n== `/newcard` — the ordering funnel\n\n`NewCardModule` (`views/pages/newcard/`).\n\n[cols=\"2,3\"]\n|===\n| `newcard.page.ts` | The ordering page.\n| `newcard-wrapper/` | Layout shell.\n| `newcard-perks/` | Card benefits presentation.\n| `newcard.constants.ts` | Page copy and option constants.\n| `shipping-address.constants.ts` / `.interface.ts` | Physical-card delivery address.\n|===\n\nBacked by the `cardOrder` slice:\n\n[listing]\n----\nloadCardProviders\n  → loadCardProvidersSuccess   which CardProvider / CardFormFactor combinations are available\n  → supplier picks provider + form factor\n  → (Airwallex only) accept cardholder terms → AirwallexCardService.saveConsent()\n  → (physical only) shipping address + optional expedite\n  → orderCard → AirwallexCardService.newCard()\n  → orderCardSuccess\n----\n\n[source,typescript]\n----\nexport enum CardProvider   { Marqeta = 'marqeta', Airwallex = 'airwallex' }\nexport enum CardFormFactor { Physical = 'PHYSICAL', Virtual = 'VIRTUAL' }\n----\n\n[IMPORTANT]\n====\nAirwallex consent must be recorded before the order is placed. The new-card page\nreads `getConsentStatus()` and only shows the consent checkbox when\n`cardholder_terms_accepted` is false. If `saveConsent()` fails, the order must not\nproceed.\n====\n\nRequest shaping differs by issuer — `recipient_address` and `expedite_card` are\nonly sent for issuers that ship a physical card; Airwallex uses the cardholder\naddress already on file.\n\n== Related surfaces\n\n[cols=\"2,3\"]\n|===\n| `/reports/card-transactions` | Standalone transaction report.\n| `widgets/seller-cards/` | Dashboard summary card.\n| `widgets/seller-cards-panel/` | The unified card panel.\n| `components/card-visual/`, `components/pcard/`, `components/awx-card/` | Card artwork.\n| `modals/seller-card-activate-modal/` | Marqeta activation.\n| `modals/seller-card-order-modal/` | Order or replace.\n| `modals/seller-card-block-modal/`, `…-unblock-modal/` | Block and unblock confirmations.\n| `modals/seller-card-view-modal/`, `modals/airwallex-card-view-modal/` | Reveal card details.\n| `modals/seller-card-transition-modal/` | Marqeta → Airwallex migration.\n| `modals/airwallex-pcard-terms-modal/` | Provider terms acceptance.\n|===\n\nFull business rules: xref:business/seller-card.adoc[Seller Card].\n"},{"component":"supplier-client","module":"ROOT","path":"features/my-marketplaces.adoc","title":"My Marketplaces","description":"Managing connected sales channels — connection status, adding channels, and reconnection.","url":"https://docx.payability.com/supplier-client/features/my-marketplaces.html","navOrder":40,"source":"= My Marketplaces\n:description: Managing connected sales channels — connection status, adding channels, and reconnection.\n\n`/my-marketplaces` — `MyMarketplacesPageModule`. Gated by the `MY_MARKETPLACES`\npermission, which every authenticated supplier holds by default.\n\nThis is where a supplier sees which sales channels Payability can read, and fixes\nthem when a connection breaks. Since advances are underwritten against\nmarketplace receipts, a broken connection has direct financial consequences.\n\n== Components\n\n[cols=\"2,3\"]\n|===\n| `my-marketplaces-page/` | The page shell and layout.\n| `marketplace-widget/` | One connected channel — logo, name, connection status, actions.\n| `marketplace-content/` | Expanded detail panel for a channel.\n| `add-marketplaces/` | The add-a-channel flow.\n| `unsuported-marketplaces/` | Report a channel Payability does not integrate with.\n| `marketplace-placeholder/` | Loading skeleton for the grid.\n| `marketplace-single-placeholder/` | Loading skeleton for one channel.\n|===\n\n== State\n\nTwo entity slices back the page:\n\n[cols=\"2,3\"]\n|===\n| `marketplaces` | The catalog of supported channels. Persisted to `localStorage` (`__marketplaces_storage__`), so the page renders instantly on repeat visits.\n| `supplierMarketplaces` | This supplier's connections and their health.\n|===\n\nBoth are keyed by `marketplaceKey`. See xref:state/entity-slices.adoc[Entity Slices].\n\n== Adding a channel\n\n[listing]\n----\nadd-marketplaces\n  → pick from the catalog (marketplaces slice)\n  → dispatch addSupplierMarketplace | bulkAddSupplierMarketplaces\n  → per-channel compliance steps (see Marketplaces & Compliance)\n  → dispatch saveSupplierMarketplace\n----\n\nChannels Payability does not support are recorded through\n`bulkAddUnsupportedSupplierMarketplaces` — they never produce a connector, but\nthey give underwriting a complete picture of the supplier's revenue.\n\n== Connection status\n\nEach connection carries a status expressed through several enums at different\ngranularities:\n\n[cols=\"2,3\"]\n|===\n| `MpPermissionEnum` | The supplier's authorization on the channel — `approved`, `verified`, `pending`, `rejected`, `incomplete`, `disabled`, `sales resolution`, and their `perm-` variants.\n| `ApiStatus` | The API connection itself — `needed`, `required`, `rejected`, `confirmed`, `incomplete`, `in-review`, `pending`.\n| `AccessStatusEnum` | The compliance record — `pending`, `incomplete`, `rejected`, `completed`, `verified`.\n|===\n\nStatus is rendered with `status-badge` and `status-chip`.\n\n== Reconnection\n\nAmazon and Shopify connections are monitored continuously by the API connectors,\nwhich raise a banner and offer a guided reconnect modal when a connection drops.\nSee xref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\nRelevant user fields:\n\n[cols=\"2,3\"]\n|===\n| `status.ascApiStatus` | Amazon Seller Central API connection status.\n| `user.amazonApiSwitching` | The supplier is mid-migration between Amazon connection methods.\n|===\n\n== Amazon flows\n\nDispatched through the `marketplaces` slice:\n\n[cols=\"2,3\"]\n|===\n| `openAmazonInviteWrapper` | The invite-Payability wizard. Also launched from the suspension modal when Amazon access has been diverted.\n| `openAmazonPermissionWrapper` | The grant-permissions wizard.\n| `amazonSuspendedProcessBreak` / `amazonSuspendedProcessCompleted` | Outcomes of the Amazon-suspended flow (`modals/amazon-suspended/`).\n|===\n\n`modals/add-selling-info-modal/` collects marketplace credentials where a manual\nconnection is required.\n\n== Cancellation interaction\n\n`CancellationBannerInitializerService` reads `fundingParticulars.lastOptOutDate`\nfrom each supplier marketplace to build the cancellation banners — so this slice\nalso drives cancellation messaging. See\nxref:business/cancellation.adoc[Cancellation & Reactivation].\n\n== Analytics\n\nTwo families of events cover this page and the onboarding equivalent:\n\n[cols=\"2,3\"]\n|===\n| `MarketplaceEventType` | Lifecycle — `Marketplace_Access_Completed`, `Marketplace_Access_Updated`, `marketplace_deleted`, `Removed_Marketplace`, `new_marketplace_added`.\n| `LinkMarketplaceEventType` | The onboarding link wizard — show more/less, per-marketplace connect clicks, `Submitted Marketplace`, `Completed_ChooseMarketplace`, `Added Additional Marketplace`.\n| `InviteMarketplaceEventType` | The invite-Payability wizard.\n|===\n\nEmitted by `MarketplaceEventsService` and `AmazonEventTrackerService`. Full list:\nxref:analytics/event-catalog.adoc[Event Catalog].\n"},{"component":"supplier-client","module":"ROOT","path":"features/product-offerings.adoc","title":"Product Offerings","description":"The cross-sell page — which products are offered, how availability is resolved, and how sign-up is routed.","url":"https://docx.payability.com/supplier-client/features/product-offerings.html","navOrder":41,"source":"= Product Offerings\n:description: The cross-sell page — which products are offered, how availability is resolved, and how sign-up is routed.\n\n`/product-offerings` — `ProductOfferingsModule`, guarded by `DocuSignGuard`.\n\nThe page presents Payability's product catalog with the supplier's personal\neligibility against each. It is also one of the two pages DocuSign returns to,\nso a contract signed from here is processed before the page renders.\n\n== Availability\n\n`ProductOfferingsService.fetchProducts(freeAccess)` calls\n`CrossSellService.fetchAvailability(supplierKey)` and turns the response into\noffer cards.\n\n[source,typescript]\n----\nfetchProducts(freeAccess: boolean): Observable<ProductOfferingsCommonOfferConfig[]> {\n    return this.crossSellService.fetchAvailability(this.user.getCurrentUserId()).pipe(\n        map((data: CrossSellAvailability) => {\n            this.setData(data);\n            const response = {};\n            Object.entries(data).forEach(([key, product]) => {\n                if ([CrossSellProductEnum.InstantAdvance, CrossSellProductEnum.AccessPlus].includes(key as CrossSellProductEnum)\n                    && product.status === CrossSellAvailabilityEnum.CannotRequest) {\n                    return;                                            // <1>\n                }\n                response[key] = productsConfig(this.injector, product, freeAccess)[key];\n            });\n            return Object.values(response);\n        }),\n    );\n}\n----\n<1> Instant Advance and Access Plus are *hidden entirely* when they cannot be\n    requested. Seller Card and Instant Transfer are always rendered, with their\n    status shown on the card.\n\n[NOTE]\n====\n`PRODUCS_NO = 3` drives a three-card skeleton (`fakeProducts$`) while the real\navailability is loading — the placeholder count is fixed regardless of how many\nproducts actually come back.\n====\n\n== The product catalog\n\n`product-offerings.config.ts` builds one `ProductOfferingsCommonOfferConfig` per\n`CrossSellProductEnum` member.\n\n[cols=\"2,2,3\"]\n|===\n| Product | Icon | Sign-up action\n\n| `SellerCard` | `pyb-credit-card` (primary) | `redirectUrl: '/seller-card'`\n| `InstantTransfer` | `pyb-zap` (warning) | `ProductOfferingsCommonOfferAction.ShowInstantTransferModal`\n| `InstantAdvance` | via `ProductInstantAdvanceModel` | Model-defined\n| `AccessPlus` | via `ProductAccessPlusModel` | Model-defined (injector-aware)\n|===\n\nSimple products are plain configuration objects; Instant Advance and Access Plus\nare *model classes* because their card content depends on runtime state.\n`ProductAccessPlusModel` additionally takes the `Injector` so it can resolve\nservices while building its configuration.\n\n== Free-access copy\n\nEvery translation key is chosen at build time based on the `freeAccess` flag:\n\n[source,typescript]\n----\nsubheaderKey: freeAccess\n    ? `${freeAccessProduct(CrossSellProductEnum.SellerCard)}.SUBHEADER`\n    : `${commonProduct(CrossSellProductEnum.SellerCard)}.SUBHEADER`,\n----\n\n`freeAccessProduct()` and `commonProduct()` (`product-offerings.utils.ts`) build\nthe namespace prefix. Four keys vary per product: `SUBHEADER`, `DESC`,\n`GENERAL_ELIGIBILITY`, `GENERAL_PRICING`. The flag comes from\n`selectIsFreeAccess`, backed by `status.isFreeAccess`.\n\n[WARNING]\n====\nAdding a product means adding *eight* translation keys — four for the common\nnamespace and four for free access. A missing free-access key renders as the raw\nkey string for that cohort only, which is easy to miss in testing.\n====\n\n== Offer modes\n\n[source,typescript]\n----\nexport enum ProductOfferingModeEnum {\n    DEFAULT                = 'DEFAULT',\n    CAN_REFINANCE          = 'CAN_REFINANCE',\n    REFINANCING_PROCESSING = 'REFINANCING_PROCESSING',\n}\n----\n\nRefinancing modes change an offer card into a refinance prompt or a\n\"processing\" state for suppliers with an existing advance.\n\n== Components\n\n[cols=\"2,3\"]\n|===\n| `product-offerings.component.ts` | Page shell; subscribes to `products$` and `loading$`.\n| `common-offer/` | The offer card — header, description, eligibility, pricing, CTA.\n| `item-with-label/` | A labelled feature row inside a card.\n| `models/product-instant-advance.model.ts` | Instant Advance card configuration.\n| `models/product-access-plus.model.ts` | Access Plus card configuration.\n| `offers/access-plus-offer.service.ts` | Access Plus sign-up behaviour.\n| `product-offerings.utils.ts` | Translation-namespace helpers.\n|===\n\n== DocuSign integration\n\n`DocuSignGuard` on this route means a contract signed from a product card returns\nhere and is processed before render. `DocusignModeEnum.ProductOfferingsList`\ntells `DocusignVerificationService.refreshData()` to call\n`ProductOfferingsService.loadProducts()` afterwards, so availability reflects the\nnew contract.\n\n== Cross-sell analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `ClickedSellerCardSignUpOnProductOfferings` | `Clicked Seller Card Sign Up on Product Offerings`\n| `ClickedInstantTransferSignUpOnProductOfferings` | `Clicked Instant Transfer Sign Up on Product Offerings`\n| `ClickedInstantAdvanceCrossSellAppliedOnProductOfferings` | `Instant Advance on Product Cross-Sell Page Applied`\n|===\n\nThe `product_cross_sell_page` dashboard notification drives traffic here via\n`CtaCrossSellStrategy` — see\nxref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n\n== Onboarding counterpart\n\n`views/onboarding/product-offering/` is a *separate* implementation used during\nonboarding, with its own components per product (`advance-line/`,\n`instant-advance/`, `instant-access/`, `access-plus/`, `new-free-access/`) and a\n`repayment-slider/`. The two are not shared. See\nxref:business/onboarding.adoc[Onboarding].\n"},{"component":"supplier-client","module":"ROOT","path":"features/other-pages.adoc","title":"Other Pages","description":"Partners, Referral, PayShield, Blog, Sales Call Summary, Send Money and Help Center.","url":"https://docx.payability.com/supplier-client/features/other-pages.html","navOrder":42,"source":"= Other Pages\n:description: Partners, Referral, PayShield, Blog, Sales Call Summary, Send Money and Help Center.\n\nThe smaller feature pages, each a lazy-loaded module.\n\n== Partners — `/partners`\n\n`PartnersModule`. A directory of Payability's partner integrations.\n\n[cols=\"2,3\"]\n|===\n| `partners.component.ts` | Page shell with category filtering.\n| `partners-list/` | The filtered list.\n| `partner-item/` | One partner card.\n| `partner-item-placeholder/` | Loading skeleton.\n| `partners.const.ts` | The partner catalog.\n| `partners.service.ts` | Data access and filtering.\n|===\n\n[source,typescript]\n----\nexport enum PartnerTypeEnum {\n    All               = 0,\n    Marketplaces      = 1,\n    EcommerceTools    = 2,\n    Shipping          = 3,\n    SolutionProviders = 4,\n}\n----\n\nNo permission gate.\n\n== Referral — `/referral`\n\n`MoreFromPayabilityModule`, containing the `referral/` sub-module. Requires the\n`REFERRAL` permission, which is denied while the supplier is onboarding.\n\n`ReferralService` builds a personalised referral link:\n\n[source,typescript]\n----\ngetReferralLink(): string {\n    const referralUrl = this.environmentService.referralUrl;\n    const user        = this.userService.getCurrentUser();\n    const referralKey = this.createReferralLinkKey(user);\n    return `${referralUrl}${referralKey}`;\n}\n\nprivate createReferralLinkKey(user: User): string {\n    return `${this.createLegalNamePart(user)}${this.createSupplierKeyPart(user)}`;\n}\n\nprivate createLegalNamePart(user: User): string {\n    return this.slugify(user.companyName).substring(0, 10);   // <1>\n}\n----\n<1> The key is a slugified, 10-character company-name prefix followed by a\n    supplier-key segment — human-recognisable but still unique.\n\nThe base URL comes from `environment.referralUrl`. `referral-banner` promotes the\nprogramme from the dashboard, gated on the same permission.\n\n== PayShield Subscription — `/payshield-subscription`\n\n`PayshieldSubscriptionModule`. A PayPal-hosted subscription checkout for the\nPayShield product.\n\n[source,typescript]\n----\nconst PAYPAL_SDK_URL = 'https://www.paypal.com/sdk/js?client-id=…&vault=true&intent=subscription';\nconst BUTTON_CONTAINER_ID = '#paypal-button-container';\n\nexport const PAYSHIELD_PLANS: PayshieldPlan[] = [\n    { id: 'P-1D465283B52778401NIP6C3I', nameKey: 'PAYSHIELD_SUBSCRIPTION.PLAN_STARTER_NAME',      descriptionKey: '…PLAN_STARTER_DESC' },\n    { id: 'P-4LB22406VJ150201WNIMJE5A', nameKey: 'PAYSHIELD_SUBSCRIPTION.PLAN_GROWTH_NAME',       descriptionKey: '…PLAN_GROWTH_DESC' },\n    { id: 'P-30A88013RL5816821NIMIGRY', nameKey: 'PAYSHIELD_SUBSCRIPTION.PLAN_PROFESSIONAL_NAME', descriptionKey: '…PLAN_PROFESSIONAL_DESC' },\n    { id: 'P-1B7255942M655732SNIMIG4Q', nameKey: 'PAYSHIELD_SUBSCRIPTION.PLAN_SCALE_NAME',        descriptionKey: '…PLAN_SCALE_DESC' },\n    { id: 'P-2VW63152FE618402ENIMIHHQ', nameKey: 'PAYSHIELD_SUBSCRIPTION.PLAN_ENTERPRISE_NAME',   descriptionKey: '…PLAN_ENTERPRISE_DESC' },\n];\n----\n\n[WARNING]\n====\nThe PayPal client id and the five plan ids are *hardcoded in the component*, not\nread from `environment`. They are therefore identical in every environment,\nincluding local development — an order placed from a dev build hits the same\nPayPal plans as production. Treat this page with care when testing.\n====\n\nThe component loads the PayPal SDK dynamically and renders the subscription\nbutton into `#paypal-button-container`. It implements `OnDestroy` to tear the SDK\ndown on navigation.\n\nNo permission gate.\n\n== Blog — `/blog`\n\n`BlogPageModule`, backed by the `blog` slice.\n\n[cols=\"2,3\"]\n|===\n| `fetchPosts` / `fetchPostsSuccess` / `fetchPostsError` | Load CMS posts.\n| `openPost` | Open a post.\n| `trackBlogNavigate` | Emits `BlogEvents.CustomerBlogPostNavigate` (`Customer_Blog_Post_Navigate`).\n|===\n\nPost bodies are rendered with the `html-render` component, which sanitizes CMS\nHTML before insertion.\n\n== Sales Call Summary — `/sales-call-summary`\n\n`SalesCallSummaryModule`. Displays the outcome of a supplier's sales call.\n\nIt is also an onboarding step: `TopBarService` registers a client-side\n`salesCallSummary` step (`ExtendedPreActivationStatusStepsKeys`) linking here, and\n`PayabilityComplianceEnum.SalesCallSummary` is the matching compliance record. See\nxref:business/onboarding.adoc[Onboarding].\n\n== Send Money — `/send-money`\n\n`SendMoneyModule`. Covered in xref:business/vendor-pay.adoc[Vendor Pay].\n\n== Help Center — `/help-center`\n\nNo module. `HelpCenterRedirectGuard` tracks\n`RouterNavigationEvents.HelpCenterLinkClicked` with the current user id, opens\n`https://help.payability.com/hc/en-us` in a new tab, and returns `false`.\n\nA Zendesk widget is also configured through `environment.zendeskWidgetUrl` and\n`environment.zendeskWidgetKey`.\n\n`constants/help-articles.const.ts` maps in-app contexts to specific help articles,\nused by `SupportHelperService` and the `hint-popover` component.\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/overview.adoc","title":"Integrations Overview","description":"Every third-party service the client talks to, what it is used for, and where it is configured.","url":"https://docx.payability.com/supplier-client/integrations/overview.html","navOrder":43,"source":"= Integrations Overview\n:description: Every third-party service the client talks to, what it is used for, and where it is configured.\n\n[cols=\"2,2,3\"]\n|===\n| Integration | Configured by | Used for\n\n| *Payability API*\n| `PybApiModule.forRoot()` — `apiUrl`, `authUrl`, `marketplaceUrl`, `b2bDocumentUrl`, `frontendActionsToolsApiUrl`, `frontendTaskManager`, `gci`\n| Everything. Services, DTOs and models come from `@payability/pyb-ng2-api`.\n\n| *Plaid*\n| `PlaidModule.forRoot({ sandbox })`\n| Bank account linking. xref:integrations/plaid.adoc[Plaid]\n\n| *DocuSign*\n| Via the API package; return URLs built by `DocusignVerificationService`\n| Contract signing. xref:business/docusign.adoc[DocuSign Contract Flows]\n\n| *Marqeta*\n| `MarqetaModule.forRoot({ sandbox })`\n| Original Seller Card issuance. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n\n| *Airwallex*\n| `environment.airwalexBaseUrl`, `AirwallexCardService`\n| Current Seller Card issuer. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n\n| *TabaPay*\n| `environment.tabapayUrl`, embedded iframe\n| Debit-card capture for Instant Transfer. xref:business/instant-transfer.adoc[Instant Transfer]\n\n| *Entrust + Onfido*\n| `EntrustIdentityService`, `onfido-sdk-ui`\n| Identity verification and re-verification. xref:integrations/entrust.adoc[Entrust Identity]\n\n| *Segment*\n| `SegmentModule.forRoot({ apiKey: segmentKey, loadOnInitialization: production })`\n| Product analytics. xref:analytics/event-logger.adoc[Event Logger]\n\n| *Smartlook*\n| `environment.smartLookKey`, `smartlook-client`\n| Session replay. xref:integrations/observability.adoc[Observability]\n\n| *AWS CloudWatch*\n| `CloudWatchModule.forRoot(cloudWatchApiUrl)`\n| Custom operational metrics. xref:integrations/observability.adoc[Observability]\n\n| *reCAPTCHA v3*\n| `RECAPTCHA_V3_SITE_KEY` = `environment.recaptchaKey`\n| Bot protection on auth forms. xref:integrations/recaptcha.adoc[reCAPTCHA]\n\n| *Zendesk*\n| `environment.zendeskWidgetUrl`, `zendeskWidgetKey`\n| Help centre widget. Also a hard redirect from `/help-center`.\n\n| *PayPal*\n| Hardcoded client id in `payshield-subscription.component.ts`\n| PayShield subscription checkout. xref:features/other-pages.adoc[Other Pages]\n\n| *AG Grid Enterprise*\n| `environment.agGridLicenseKey`, set by `initializeAgGridLicenseProvider`\n| Every report grid. xref:features/reports.adoc[Reports]\n\n| *Amazon Seller Central / Shopify*\n| `ApiConnectorsService`\n| Marketplace connection health. xref:integrations/marketplace-connectors.adoc[Marketplace API Connectors]\n|===\n\n== Where configuration comes from\n\nEverything except the PayPal plan ids resolves from `environment`, which is\ngenerated at build time from `config/environment.template` — see\nxref:architecture/bootstrap.adoc[Bootstrap & Initialization].\n\n[WARNING]\n====\nThe PayShield PayPal client id and plan ids are hardcoded in the component, so\nthey are identical in every environment including local development.\n====\n\n== Sandbox toggles\n\n[cols=\"2,3\"]\n|===\n| `marquetaSandbox` | Switches the Marqeta SDK to sandbox.\n| `plaidSandbox` | Switches Plaid Link between `sandbox` and `production` (there is no `development` path in the factory).\n| `production` | Gates Segment auto-load, the service worker, `storeFreeze` and dev-only permissions.\n|===\n\n== The vendor wrapper pattern\n\n`src/app/vendors/` holds thin in-repo modules that expose a third-party service as\nan Angular module with a `forRoot()` config and an injection token:\n\n[cols=\"2,3\"]\n|===\n| `vendors/plaid/` | `PlaidModule`, `PLAID_CONFIG`, `PLAID_CONFIG_DATA`, `plaidDefaultConfig`.\n| `vendors/cloud-watch/` | `CloudWatchModule`, `CloudWatchService`, `CLOUD_WATCH_API_URL`.\n|===\n\nConsumers inject the token rather than reading `environment` directly, which keeps\nthe SDK swappable and testable.\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/api-client.adoc","title":"API Client","description":"How the client talks to the backend — the generated pyb-ng2-api package, the six base URLs, EnvironmentService, and the cases that bypass the client.","url":"https://docx.payability.com/supplier-client/integrations/api-client.html","navOrder":44,"source":"= API Client\n:description: How the client talks to the backend — the generated pyb-ng2-api package, the six base URLs, EnvironmentService, and the cases that bypass the client.\n\nThe application makes almost no hand-written HTTP calls. Backend access goes\nthrough `@payability/pyb-ng2-api`, a private package published to GitHub Packages\nthat ships typed services, DTOs, models and enums for the Payability APIs.\n\n[cols=\"1,3\"]\n|===\n| Package | `@payability/pyb-ng2-api`\n| Registry | GitHub Packages (`@payability` scope — needs a token, see xref:development/setup.adoc[Local Setup])\n| Module | `PybApiModule.forRoot(config)`\n| Surface | ~44 service files under `lib/services/`, plus `dto/`, `models/`, `enums/`, `params/`, `responses/`, `headers/`, `interfaces/`, `tokens/`\n|===\n\nBecause it is a separate package, its internals are documented by its own\ncomponent in the central Antora site. This page documents *how this application\nconsumes it*.\n\n== Registration\n\n`AppModule` configures the client once, from the generated environment:\n\n[source,typescript]\n----\nPybApiModule.forRoot({\n    baseUrl:                    environment.apiUrl,\n    marketplaceUrl:             environment.marketplaceUrl,\n    frontendActionsToolsApiUrl: environment.frontendActionsToolsApiUrl,\n    b2bDocumentUrl:             environment.b2bDocumentUrl,\n    gci:                        environment.gci,\n    frontendTaskManager:        environment.frontendTaskManager,\n    documentsApiUrl:            environment.apiUrl,      // <1>\n}),\n----\n<1> Documents share the main API host; the config field exists so they can be\n    split later.\n\nThe config shape is `ApiServiceConfig`:\n\n[source,typescript]\n----\nexport interface ApiServiceConfig {\n    baseUrl: string;\n    marketplaceUrl: string;\n    frontendActionsToolsApiUrl: string;\n    b2bDocumentUrl: string;\n    documentsApiUrl?: string;\n    frontendTaskManager: string;\n    gci: string;\n    cloudWatchApiUrl?: string;\n}\n----\n\nEach value becomes a DI token that the generated services inject:\n\n[cols=\"2,3\"]\n|===\n| `BASE_URL_TOKEN` | The main supplier API.\n| `MARKETPLACE_URL_TOKEN` | Marketplace web API — connection health, invites, credentials.\n| `ACTION_TOOLS_API_URL_TOKEN` | Frontend actions tools.\n| `B2B_DOCUMENTS_API_URL_TOKEN` / `DOCUMENTS_API_URL_TOKEN` | Document upload and retrieval.\n| `B2B_TASK_MANAGER_API_URL_TOKEN` | Task manager.\n| `MASK_MANAGER_GCI_TOKEN` | The `gci` value — a per-environment identifier derived from a SHA-1 of `ENV_NAME`.\n| `CLOUD_WATCH_API_URL_TOKEN` | Metrics endpoint. Not passed here; `CloudWatchModule` supplies it separately.\n|===\n\n[IMPORTANT]\n.`cloudWatchApiUrl` is deliberately absent\n====\n`PybApiModule.forRoot()` accepts `cloudWatchApiUrl` but this app does not pass it.\nCloudWatch is wired through the in-repo `vendors/cloud-watch/` module with its own\ntoken instead. See xref:integrations/observability.adoc[Observability].\n====\n\n[NOTE]\n====\n`PybApiModule` exports `HttpClientModule`, so `AppModule` gets `HttpClient` from\nthe API module rather than importing it directly. That is also why the interceptors\nregistered in `CoreModule` apply to generated-client calls — they all share one\n`HttpClient`.\n====\n\n== `EnvironmentService`\n\n`src/environments/environment.service.ts` is the *type* for the generated\n`environment` object, and is also provided so it can be injected:\n\n[source,typescript]\n----\n@Injectable()\nexport class EnvironmentService {\n    env?: string;\n    version?: string;\n    buildTime?: string;\n    production: boolean;\n    isMockEnabled: boolean;\n    authTokenKey: string;\n    airwalexBaseUrl: string;\n    apiUrl: string;\n    authUrl: string;\n    gci: string;\n    frontendTaskManager: string;\n    marketplaceUrl: string;\n    frontendActionsToolsApiUrl: string;\n    b2bDocumentUrl: string;\n    tabapayUrl: string;\n    agGridLicenseKey: string;\n    segmentKey: string;\n    referralUrl: string;\n    marquetaSandbox: boolean;\n    plaidSandbox: boolean;\n    cloudWatchApiUrl: string;\n    zendeskWidgetUrl: string;\n    zendeskWidgetKey: string;\n    recaptchaKey: string;\n    smartLookKey: string;\n}\n----\n\nInjecting `EnvironmentService` is preferred over importing the `environment`\nconstant, because it is mockable in tests. Roughly two dozen services do so.\n\n[WARNING]\n====\nAdding a setting means touching *four* places, or the build breaks or silently\nships an empty string:\n\n. `config/config.js` — the per-environment value\n. `config/environment.template` — the EJS line that emits it\n. `src/environments/environment.service.ts` — the field declaration\n. `src/environments/environment.prod.ts` — if production differs\n\n`environment.ts` itself is generated. See xref:development/setup.adoc[Local Setup].\n====\n\n`authUrl` and `airwalexBaseUrl` are *not* passed to `PybApiModule` — they are used\ndirectly by `AuthService` and `AirwallexCardService`, which build their own\nrequests.\n\n== Service catalog\n\nThe generated services this application actually imports, grouped by area. Names\nare the exported classes; files live in `lib/services/`.\n\n=== Supplier and account\n\n[cols=\"2,3\"]\n|===\n| `SuppliersService` | Supplier details, status, account creation, available payment methods. The most-used service in the app.\n| `OnboardingService` | Pre-activation status, `submitApplication()`.\n| `PreSubmissionStepsStatusService` | Pre-submission step state.\n| `BusinessOwnersService`, `BusinessTypesService` | Business profile reference data.\n| `ShippingAddressService` | Shipping address for physical cards.\n| `CountriesService`, `StatesService` | Geographic reference data.\n| `ComplianceActionsService` | Compliance records and required actions.\n| `DocumentsService` | Document upload, listing, retrieval.\n| `LoginsService` | Login history.\n| `ChangePasswordService` | Authenticated password change.\n| `DashboardMessageService` | The server-driven announcement banner.\n| `NewSdFeedbackService`, `OldSdSwitchService` | Dashboard-version feedback and switching.\n| `CancellationSurveyService`, `CancellationWorkflowApiService` | Deactivation. xref:business/cancellation.adoc[Cancellation & Reactivation]\n|===\n\n=== Money and products\n\n[cols=\"2,3\"]\n|===\n| `BalanceService` | Available balance.\n| `AdvanceLineService` | Advance Line summary, offers, withdrawals. xref:business/advance-line.adoc[Advance Line]\n| `CrossSellService` | Product offering availability. xref:features/product-offerings.adoc[Product Offerings]\n| `ProductSummaryService` | The dashboard product summary.\n| `PaymentMethodService`, `PaymentFlowsService` | Payout methods (`services/payments/`) and repayment flows.\n| `TransferFundsService` | Transfer requests.\n| `FeeScheduleService` | The fee schedule behind the `fees` slice.\n| `RebatePunchCardService` | Punch-card rewards.\n| `OpenInvoicesService` | Open invoices.\n| `MonthEndCloseService` | Month-end close state.\n| `SendMoneyService` | Vendor Pay outgoing payments. xref:business/vendor-pay.adoc[Vendor Pay]\n|===\n\n=== Cards and banking\n\n[cols=\"2,3\"]\n|===\n| `MarqetaService` | Seller Card issuance, activation, reveal. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n| `CardTransactionService` | Card transaction report.\n| `PlaidService` | Link token exchange. xref:integrations/plaid.adoc[Plaid]\n| `SwiftService` | Routing/SWIFT verification — used as an async validator. xref:architecture/forms-and-fields.adoc[Forms, Fields and Validation]\n|===\n\n=== Marketplaces\n\n[cols=\"2,3\"]\n|===\n| `MarketplacesService` | Supplier marketplaces, invites, credentials.\n| `AmazonService` | Amazon-specific connection operations. xref:integrations/marketplace-connectors.adoc[Marketplace API Connectors]\n|===\n\n=== Contracts\n\n[cols=\"2,3\"]\n|===\n| `DocuSignService` | One URL-generating method per flow — `getOnboardingActivationDocuSignUrl()`, and the Advance Line, Instant Advance, LTC and re-sign equivalents. xref:business/docusign.adoc[DocuSign Contract Flows]\n|===\n\n=== Reports\n\n[cols=\"2,3\"]\n|===\n| `DailySummaryReportService` | Daily summary.\n| `TransferReportService` | Transfers.\n| `MarketplacePaymentService` | Marketplace payments and transaction details.\n| `FeeReportService` | Fees.\n| `InstantAdvanceReportService` | Advance report.\n| `ReconciliationViewService` | Reconciliation view.\n| `ContractsOverviewReportService` | Commitment overview.\n| `ExportCSVService`, `PybStatementService` | Export and statement generation.\n|===\n\nReport data is consumed through the datasource layer rather than called directly\nfrom components — see xref:features/reports.adoc[Reports].\n\n=== Infrastructure\n\n[cols=\"2,3\"]\n|===\n| `B2bTaskManagerService` | Task manager operations.\n| `NotificationsService` | Dashboard notification operations, under `services/action-tools/` and served by `frontendActionsToolsApiUrl`.\n| `B2bDocumentService` | B2B document API.\n| `SegmentService` | Server-side Segment helpers. The client uses `EventLoggerService` instead. xref:analytics/event-logger.adoc[Event Logger]\n| `CloudWatchService` | Present in the package, but the app uses its own `vendors/cloud-watch/` implementation.\n|===\n\nFull local service inventory: xref:reference/services.adoc[Service Catalog].\n\n== Request lifecycle\n\nEvery generated-service call goes through the same chain, because there is one\n`HttpClient`:\n\n[listing]\n----\nSomeApiService.method(args)\n  → HttpClient\n      → AuthInterceptor              adds Authorization / session headers\n      → UnauthorizedInterceptor      401 → logout + redirect\n      → HttpErrorHandlerInterceptor  toasts, special-cases ACCOUNT_LOCKED\n  → response (typed DTO)\n----\n\nDetails in xref:security/interceptors.adoc[HTTP Interceptors]. The practical\nconsequence: *never* add auth headers or error toasts at the call site. The\ninterceptors already did it.\n\n== Calls that bypass the generated client\n\nTwelve services build requests with `HttpClient` directly. They are not mistakes\nin every case, but each is a place where a URL is assembled by hand:\n\n[cols=\"2,3\"]\n|===\n| Service | Why\n\n| `AuthService` (`core/auth/_services/`)\n| Targets `authUrl`, not `baseUrl`, and needs Basic auth plus a `recaptcha` header. xref:security/authentication.adoc[Authentication]\n\n| `AirwallexCardService`\n| Targets `airwalexBaseUrl`. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n\n| `EntrustIdentityService`\n| Entrust session creation. xref:integrations/entrust.adoc[Entrust Identity]\n\n| `CloudWatchService` (`vendors/cloud-watch/`)\n| Own module and own URL token. xref:integrations/observability.adoc[Observability]\n\n| `BlogService`\n| CMS endpoint, outside the supplier API.\n\n| `EmailPreferenceService`\n| Email preference endpoints not yet in the package.\n\n| `ProductSummaryService` (`core/pyb/services/widgets/`)\n| Predates `ProductSummaryService` in the package.\n\n| `ProductOfferingService` (`views/onboarding/product-offering/`)\n| `+GET /suppliers/{id}/approved_products+`, `+POST /suppliers/{id}/accept_products+`. xref:features/onboarding-pages.adoc[Onboarding Pages]\n\n| `TransferService` (`your-account-widget/`)\n| Widget-local transfer call.\n\n| `OutstandingBalanceService`, and the two `VendorpayReportsService` implementations\n| Report endpoints assembled locally alongside their column configs.\n|===\n\n[NOTE]\n.When to add a raw call\n====\nPrefer extending `@payability/pyb-ng2-api` — a typed method there is reusable and\nversioned. Reach for `HttpClient` only when the endpoint lives on a host the\npackage does not know about, or when you are prototyping against an endpoint that\nis not yet in the contract. If you do, inject `EnvironmentService` for the host\nrather than hardcoding it, and let the interceptors handle auth and errors.\n====\n\n== Mocking in tests\n\nGenerated services are mocked like any other provider, following the\n`<Resource>ServiceMock` convention. See xref:development/testing.adoc[Testing].\n\n== Related pages\n\n* xref:security/interceptors.adoc[HTTP Interceptors]\n* xref:reference/models.adoc[Model Reference] — the local models DTOs are mapped into\n* xref:reference/services.adoc[Service Catalog] — the in-repo services that wrap these\n* xref:development/setup.adoc[Local Setup] — registry auth and environment generation\n* xref:integrations/overview.adoc[Integrations Overview]\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/marketplace-connectors.adoc","title":"Marketplace API Connectors","description":"Continuous connection-health monitoring for Amazon and Shopify, with reconnect banners and guided modals.","url":"https://docx.payability.com/supplier-client/integrations/marketplace-connectors.html","navOrder":45,"source":"= Marketplace API Connectors\n:description: Continuous connection-health monitoring for Amazon and Shopify, with reconnect banners and guided modals.\n\n`src/app/core/api-connectors/` monitors whether Payability can still read a\nsupplier's marketplace data, and prompts a reconnect when it cannot. Two\nconnectors are live: Amazon and Shopify.\n\n== Orchestration\n\n`ApiConnectorsService.init()` is called from\n`GlobalSubscriptionsService.init()`.\n\n[source,typescript]\n----\npublic init(): void {\n    if (!this.permission$) {\n        this.store.dispatch(fetchSupplierMarketplaces());\n\n        this.permission$ = this.store.select(currentUserPermissions).pipe(\n            filter(this.arePermissionsExisting),\n            take(1),\n            map(this.remapPermissions),\n        ).subscribe((permissions: Map<number, Permission>) => {\n            if (permissions.get(PermissionsEnum.SHOPIFY_API_CONNECTION)) { this.shopify.init(); }\n            if (permissions.get(PermissionsEnum.AMAZON_API_CONNECTION))  { this.amazon.init(); }\n        });\n    }\n}\n----\n\nBoth permissions are default-granted, so both connectors normally start. The\n`permission$` guard makes `init()` idempotent.\n\n== `AbstractConnectorApi`\n\nThe shared base class. Subclasses supply five members:\n\n[source,typescript]\n----\nprotected abstract readonly bannerSuccessKey: string;              // translation key\nprotected abstract readonly bannerFailKey: string;                 // translation key\nprotected abstract readonly bannerComponent: Type<AbstractConnectorBanner>;\nprotected abstract readonly modalComponent: Type<AbstractConnectorModal>;\nprotected abstract readonly storeKey: MarketplaceEnum;\nprotected abstract handleModalResult(result: boolean): void;\n----\n\n=== When the check runs\n\n[source,typescript]\n----\nprivate isProperRouterEvent(event: RouterEvent): boolean {\n    return !!event\n        && event instanceof NavigationEnd\n        && event.urlAfterRedirects.includes('/dashboard')     // <1>\n        && event.url\n        && !event.url.includes('my-marketplaces');            // <2>\n}\n----\n<1> The check only fires on navigation to the dashboard.\n<2> Navigation *from* `/my-marketplaces` is excluded — the supplier is already on\n    the page where they would fix the connection, so interrupting them there is\n    counterproductive.\n\n`init()` subscribes to router events and also runs one immediate check.\n\n=== What it checks\n\n[source,typescript]\n----\nthis.store$ = combineLatest([\n    this.store.pipe(select(loadedSupplierMarketplaceSuccess),\n                    filter((result: any) => !result?.supplierMarketplaces?.isLoading)),\n    this.store.pipe(select(selectSupplierMarketplaceByKey, { key: this.storeKey }), filter(Boolean)),\n    this.store.pipe(select(selectCurrentUser), filter(user => this.isUserNotDuringOnboarding(user))),   // <1>\n]).pipe(\n    filter(() => this.isProperRouterEvent(this.lastEvent)),\n    take(1),\n).subscribe(([_, marketplace]) => this.handleMarketplace(marketplace as SupplierMarketplacesModel));\n----\n<1> Suppliers still in onboarding are never prompted — connections are expected to\n    be incomplete at that stage.\n\n=== The reconnect decision\n\n[source,typescript]\n----\npublic shouldDisplayPopup(marketplace: SupplierMarketplacesModel): boolean {\n    return coerceBooleanProperty(marketplace)\n        && [ApiStatus.Required.valueOf(), ApiStatus.Needed.valueOf()].includes(marketplace.apiStatus?.toLowerCase())\n        && !marketplace.draft\n        && !marketplace.marketplaceStatus?.hidden\n        && !marketplace.marketplaceStatus?.rejected;\n}\n----\n\nOnly `required` and `needed` prompt. `rejected`, `in-review`, `pending`,\n`incomplete` and `confirmed` do not — there is nothing the supplier can usefully\ndo.\n\n[source,typescript]\n----\nprotected handleMarketplace(marketplace: SupplierMarketplacesModel): void {\n    if (this.shouldDisplayPopup(marketplace)) {\n        this.showReconnectBanner();\n        this.verify();                    // open the guided modal\n    } else {\n        this.preventCheck = true;         // <1>\n    }\n}\n----\n<1> Once a connection is healthy, `preventCheck` stops all further checks for the\n    session — the connector does not re-poll after a clean result.\n\n=== Banners\n\n[cols=\"2,3\"]\n|===\n| Reconnect | `BannerTypes.DANGER`, message from `bannerFailKey`, `hideClose: true` (cannot be dismissed), rendering the connector's own banner component so it can host a Reconnect CTA.\n| Connected | `BannerTypes.SUCCESS`, message from `bannerSuccessKey`, dismissible.\n|===\n\nShowing one closes the other, and both are tracked in fields so they are never\nduplicated.\n\n== Amazon connector\n\n[cols=\"2,3\"]\n|===\n| `bannerSuccessKey` | `MODALS.AP_AMAZON.SUCCESS`\n| `bannerFailKey` | `MODALS.AP_AMAZON.FAIL`\n| `storeKey` | `MarketplaceEnum.Amazon`\n| Components | `AmazonBannerComponent`, `AmazonModalComponent`\n|===\n\nAmazon's OAuth handshake happens on Amazon's domain, so the return is detected\nwith a *cookie* rather than a query parameter.\n\n=== `AmazonConnectorCookieService`\n\n[cols=\"2,3\"]\n|===\n| `payability-amazon-connection` | Set before leaving for Amazon Seller Central; its presence on return means \"we just came back\".\n| `payability-amazon-connection-my-marketplace` | Same, for a connection started from `/my-marketplaces`.\n| Domain | `.payability.com`\n| Max age | 24 hours\n|===\n\nCookies are removed by rewriting them with `max-age=-99999`.\n\n=== Constructor behaviour\n\n[source,typescript]\n----\nthis.isCookieAdded = this.amazonCookie.isAmazonCookieAdded;\nif (this.isCookieAdded) {\n    this.amazonCookie.removeAmazonCookie();\n}\nif (this.amazonCookie.isAmazonMyMarketplaceCookieAdded) {\n    this.amazonCookie.removeAmazonMyMarketplaceCookie();\n    this.router.navigate([URLS_ROUTER.MARKETPLACE.MY_MARKETPLACES],\n                         { queryParams: { 'open-amazon': true } }).then();   // <1>\n}\n----\n<1> A supplier who started the connection from `/my-marketplaces` is returned\n    there with `?open-amazon=true`, which reopens the Amazon panel — they land\n    back exactly where they were.\n\n=== Success detection\n\n[source,typescript]\n----\nprivate isConnectionConfirmedAndUrlContainsMark(amazon: SupplierMarketplacesModel): boolean {\n    return coerceBooleanProperty(amazon) && ApiStatus.Confirmed === amazon.apiStatus && this.isCookieAdded;\n}\n----\n\nConfirmed status *plus* the return cookie means the reconnect just succeeded, so\nthe success banner is shown instead of running the normal check.\n\n[NOTE]\n====\n`handleModalResult()` for Amazon unconditionally shows the reconnect banner. The\nsupplier leaves for Amazon, so the modal closing is not evidence of success —\nconfirmation only comes on the next load, via the cookie.\n====\n\n[WARNING]\n====\nThe cookie domain is hardcoded to `.payability.com`. On a `localhost` dev server\nthe cookie is not set, so the Amazon return-detection path cannot be exercised\nlocally.\n====\n\n== Shopify connector\n\n[cols=\"2,3\"]\n|===\n| `bannerSuccessKey` | `MODALS.AP_SHOPIFY.SUCCESS`\n| `bannerFailKey` | `MODALS.AP_SHOPIFY.FAIL`\n| `storeKey` | `MarketplaceEnum.Shopify`\n| Components | `ShopifyBannerComponent`, `ShopifyModalComponent`\n|===\n\nShopify verifies inline instead — when the modal closes, the connector refetches\nthe marketplace list and checks the result:\n\n[source,typescript]\n----\nprotected handleModalResult(): void {\n    this.store.select(selectCurrentUserId).pipe(\n        mergeMap(id => this.marketplacesService.getSupplierMarketplacesList(id)),\n    ).subscribe((marketplaces: SupplierMarketplacesModel[]) => {\n        this.store.dispatch(loadedSupplierMarketplaceSuccess({ payload: marketplaces }));\n        const shopify = marketplaces?.find(m => m.marketplaceKey === MarketplaceEnum.Shopify);\n        if (shopify?.apiStatus === 'confirmed') {\n            this.preventCheck = true;\n            this.unsubscribe();\n            this.showConnectedBanner();\n        } else {\n            this.showReconnectBanner();\n        }\n    });\n}\n----\n\n[NOTE]\n====\nThe comparison uses the string literal `'confirmed'` rather than\n`ApiStatus.Confirmed`. Equivalent today, but it bypasses the enum.\n====\n\n== DI tokens\n\n[source,typescript]\n----\nexport const AMAZON_API_CONNECTOR  = new InjectionToken<AmazonConnectorApiService>('amazon-api-connector');\nexport const SHOPIFY_API_CONNECTOR = new InjectionToken<ShopifyConnectorApiService>('shopify-api-connector');\n----\n\n== Adding a connector\n\n. Create `core/api-connectors/<marketplace>/`.\n. Extend `AbstractConnectorApi`, supplying the five abstract members and\n  `handleModalResult()`.\n. Build a banner component implementing `AbstractConnectorBanner` (`verify()`)\n  and a modal implementing `AbstractConnectorModal` (`connect()`).\n. Add `MODALS.AP_<NAME>.SUCCESS` and `.FAIL` translation keys.\n. Add a permission to `PermissionsEnum` and `permissions.const.ts`, and grant it.\n. Initialize the connector in `ApiConnectorsService.init()` behind that permission.\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/plaid.adoc","title":"Plaid","description":"Bank account linking — module configuration, the OAuth redirect dance, and the notifications that trigger it.","url":"https://docx.payability.com/supplier-client/integrations/plaid.html","navOrder":46,"source":"= Plaid\n:description: Bank account linking — module configuration, the OAuth redirect dance, and the notifications that trigger it.\n\nPlaid Link is used to connect a supplier's bank account for payouts and for\nunderwriting evidence.\n\n== Module configuration\n\n`src/app/vendors/plaid/`\n\n[source,typescript]\n----\nexport const plaidConfigFactory = (config: PlaidConfigData): PlaidLinkConfig => ({\n    ...plaidDefaultConfig,\n    env: config.sandbox ? PlaidEnv.Sandbox : PlaidEnv.Production,\n});\n\n@NgModule()\nexport class PlaidModule {\n    static forRoot(config: PlaidConfigData): ModuleWithProviders<PlaidModule> {\n        return {\n            ngModule: PlaidModule,\n            providers: [\n                { provide: PLAID_CONFIG_DATA, useValue: config },\n                { provide: PLAID_CONFIG, useFactory: plaidConfigFactory, deps: [PLAID_CONFIG_DATA] },\n            ],\n        };\n    }\n}\n----\n\nDefault configuration:\n\n[source,typescript]\n----\nexport const plaidDefaultConfig: PlaidLinkConfig = {\n    products:     [PlaidProducts.Assets],\n    env:          PlaidEnv.Sandbox,\n    countryCodes: [PlaidCountryCodes.Us],\n};\n----\n\n[cols=\"2,3\"]\n|===\n| `products` | `Assets` only. The `Transactions`, `Auth` and `Identity` members exist in `PlaidProducts` but are not requested.\n| `countryCodes` | `US` only — Plaid linking is not offered outside the United States.\n| `env` | `sandbox` or `production`, from `environment.plaidSandbox`. `PlaidEnv.Development` is never selected.\n|===\n\n`AppModule` wires it with `PlaidModule.forRoot({ sandbox: environment.plaidSandbox })`.\n\n== The OAuth redirect\n\nSome institutions require a full-page OAuth redirect, so the Link session must be\nresumed after the browser returns. `AppModule` handles this in its constructor,\n*before* any route is activated:\n\n[source,typescript]\n----\nprivate handlePlaid(): void {\n    const urlParams = new URLSearchParams(window.location.search);\n    if (urlParams.get('oauth_state_id')) {\n        this.oauthTokenService.oauthToken = window.location.href;          // <1>\n        this.oauthTokenService.linkToken  = localStorage.getItem('link_token');\n        localStorage.removeItem('link_token');                             // <2>\n    }\n}\n----\n<1> The whole return URL becomes `receivedRedirectUri` for the resumed session.\n<2> `link_token` was stored before the redirect; it is read once and removed so a\n    stale token cannot resume a later session.\n\n`OAuthTokenService` holds both values for the component that reopens Link.\n\n[NOTE]\n====\n`oauth_state_id` is read from `window.location.search`, not from the Angular\nrouter. With hash routing the parameter sits before the `#`, so the router never\nsees it — hence the constructor-level handling.\n====\n\n== State\n\n[cols=\"2,3\"]\n|===\n| Slice | `plaid` (`PlaidFeatureModule`)\n| Actions | `savePlaidToken`, `savePlaidTokenSuccess`, `savePlaidTokenFailed`, `savePlaidTokenRefresh`\n|===\n\n[source,typescript]\n----\nexport const savePlaidTokenRefresh = createAction(\n    PlaidActionTypes.savePlaidTokenRefresh,\n    props<{ externalProductId: string }>(),\n);\n----\n\n`savePlaidTokenRefresh` covers *re-authentication*: an existing Plaid item whose\ncredentials have expired must be re-linked against the same\n`externalProductId` rather than creating a new connection.\n\nThe linked account's masked number is surfaced on the user as\n`user.plaidAccountMask`.\n\n== Entry points\n\n[cols=\"2,3\"]\n|===\n| Onboarding | The `WithPlaid` schema (`3.0_plaid`) requires Plaid linking at the repayment-information step. See xref:business/onboarding.adoc[Onboarding].\n| Transfer Information | Linking or relinking from `/manage-account/transfer-information`.\n| Dashboard notifications | `collection_account` (`DashboardNotificationType.PlaidCollection`) and `advance_underwriting` (`PlaidAdvance`) prompt a link.\n| Bank verification check | `BankVerificationInfoModalService.check(plaidConfig)`, called from `GlobalSubscriptionsService.init()` with the injected `PLAID_CONFIG`.\n|===\n\n== Lost connection\n\n`modals/lost-bank-connection-modal/` prompts the supplier to re-link when the\nconnection drops, and `BannerService` shows a persistent banner alongside it.\n\n== Related components\n\n[cols=\"2,3\"]\n|===\n| `modals/bank-verification-info-modal/` | Explains why bank verification is needed.\n| `modals/lost-bank-connection-modal/` | Re-link prompt.\n| `modals/transfer-information/` | Payout method management.\n| `modals/switch-cd-dialog/` | Switch between checking and deposit accounts.\n| `widgets/payments-config-widget/` | Dashboard summary of the configured method.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/card-issuers.adoc","title":"Marqeta & Airwallex","description":"The two card issuers, how they differ, and how the client keeps them behind one interface.","url":"https://docx.payability.com/supplier-client/integrations/card-issuers.html","navOrder":47,"source":"= Marqeta & Airwallex\n:description: The two card issuers, how they differ, and how the client keeps them behind one interface.\n\nThe Seller Card was originally issued by *Marqeta* and is being migrated to\n*Airwallex*. Both are live simultaneously, so the client must present a coherent\n\"Your Cards\" experience across two very different integrations.\n\nThe business rules are in xref:business/seller-card.adoc[Seller Card]; this page\ncovers the integration mechanics.\n\n== Marqeta\n\n[cols=\"2,3\"]\n|===\n| Module | `MarqetaModule.forRoot({ sandbox: environment.marquetaSandbox })`, in `src/app/core/marqeta/`\n| Modelled as | A *payment method* — lives in the `paymentMethods` slice\n| Status enum | `PCardStatus` (`Active`, `Suspended`, `Unactivated`, `Terminated`)\n| Form factor | `PCardTypeIndicator`, derived from Marqeta's `instrument_type` string\n| Key | `paymentConfigKey` on the payment method; `card_token` on the p-card\n|===\n\nBecause it is a payment method, Marqeta card actions and payout-method actions\nshare a slice. Actions such as `sellerCardRequest`, `sellerCardSuccessResponse`,\n`sellerCardActivate` and `sellerCardActivateSuccess` sit alongside\n`savePaymentMethod` and `makePaymentMethodAsDefault`.\n\n`instrument_type` is a free-form string, normalized with a substring check:\n\n[source,typescript]\n----\nconst marqetaFormFactor = (instrumentType: string | undefined): string =>\n    (instrumentType ?? '').toUpperCase().includes('VIRTUAL')\n        ? PCardTypeIndicator.Virtual\n        : PCardTypeIndicator.Physical;\n----\n\nValues like `PHYSICAL_ICC` and `VIRTUAL_PAN` therefore both resolve correctly, and\nanything unrecognised defaults to physical.\n\n== Airwallex\n\n[cols=\"2,3\"]\n|===\n| Base URL | `environment.airwalexBaseUrl`\n| Service | `AirwallexCardService` (`core/pyb/services/airwallex-card.service.ts`)\n| Modelled as | A *card* — lives in the `airwallexCards` slice\n| Status enum | `AirwallexCardStatus` (`ACTIVE`, `INACTIVE`, `CLOSED`, `PENDING`)\n| Form factor | `CardFormFactor` (`PHYSICAL`, `VIRTUAL`)\n| Key | `cardId`\n|===\n\n=== Endpoints\n\n[cols=\"2,3\"]\n|===\n| `+GET /suppliers/{key}/cards+` | Unified list across both issuers, each entry tagged with `provider`.\n| `+GET /suppliers/{key}/card_providers+` | Which issuers and form factors the supplier may order.\n| `+GET /suppliers/{key}/airwallex+` | Cardholder consent status (`cardholder_terms_accepted`).\n| `+POST /suppliers/{key}/airwallex/consent+` | Record consent.\n| `+POST /suppliers/{key}/cards/newcard+` | Order a card.\n| `+POST /suppliers/{key}/airwallex/pan-token+` | Short-lived token for revealing the PAN.\n| `+POST /suppliers/{key}/cards/block/{cardId}+` | Suspend, with `{ provider: Airwallex }` in the body.\n| `+POST /suppliers/{key}/cards/activate/{cardId}+` | Activate or unblock.\n|===\n\n[NOTE]\n====\n`/cards`, `/cards/newcard`, `/cards/block/*` and `/cards/activate/*` are\n*unified* endpoints — the issuer is selected by the `provider` field, not by the\npath. Only the consent and PAN-token endpoints are Airwallex-specific.\n====\n\n=== Consent\n\n[source,typescript]\n----\n/**\n * Persist the supplier's acceptance of the Airwallex cardholder terms + privacy policy.\n * Must be recorded before a card is ordered — if this fails, the order must not proceed.\n */\nsaveConsent(supplierKey: string): Observable<void> {\n    return this.http.post<void>(`${this.env.apiUrl}/suppliers/${supplierKey}/airwallex/consent`, {});\n}\n----\n\nConsent is required both for *new* Airwallex cards and for *migrating* existing\nMarqeta cardholders. The migration path is delivered as the\n`airwallex_pcard_terms` dashboard notification.\n\n=== Order request shaping\n\n[source,typescript]\n----\nnewCard(supplierKey, { provider, formFactor, recipientAddress, expediteCard }) {\n    return this.http.post(`${this.env.apiUrl}/suppliers/${supplierKey}/cards/newcard`, {\n        provider,\n        form_factor: formFactor,\n        ...(recipientAddress ? { recipient_address: recipientAddress } : {}),\n        ...(expediteCard != null ? { expedite_card: expediteCard } : {}),\n    });\n}\n----\n\n`recipient_address` and `expedite_card` are omitted for Airwallex, which ships to\nthe cardholder address already on file. Only issuers with physical shipping —\nMarqeta — receive them.\n\n== The unification layer\n\n`core/pyb/interfaces/unified-supplier-card.interface.ts` maps both issuers onto\n`UnifiedSupplierCard` with a common status, form factor, label keys and colours,\nretaining the original model in `raw` so actions can be routed correctly.\n\nStatus normalisation:\n\n[cols=\"2,2,2\"]\n|===\n| `UnifiedCardStatus` | `PCardStatus` | `AirwallexCardStatus`\n\n| `Active` | `Active` | `ACTIVE`\n| `Blocked` | `Suspended` | `INACTIVE`\n| `Pending` | `Unactivated` | `PENDING`\n| `Closed` | `Terminated` | `CLOSED`\n|===\n\nWhen a Marqeta card appears in `/cards` without a matching payment method,\n`synthesizeMarqetaPaymentMethod()` fabricates a minimal stand-in carrying the\ncard token. That is sufficient for block and activate; the *view details* flow\nneeds a real `paymentConfigKey` and is only offered when a genuine payment method\nwas matched.\n\n== Card data handling\n\n[IMPORTANT]\n====\nThe application never handles a raw PAN, CVV or PIN. Card details are revealed\nthrough a provider-issued short-lived token (`getPanToken()` for Airwallex)\nrendered inside the provider's own secure surface, and every reveal is behind a\n2FA challenge.\n====\n\n== Decline codes\n\n`NetworkResponseCodeEnum` maps roughly thirty issuer decline codes to explanatory\ncopy — expired card, lost or stolen pick-up, insufficient funds, limit exceeded,\nCVV failure, account blocked, suspected fraud, closed account, and the\nMastercard Send-specific `ZN` / `ZD` / `ZR` codes. See\nxref:reference/enums.adoc[Enum Reference].\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/entrust.adoc","title":"Entrust Identity","description":"Identity verification and re-verification through Entrust, rendered with the Onfido SDK.","url":"https://docx.payability.com/supplier-client/integrations/entrust.html","navOrder":48,"source":"= Entrust Identity\n:description: Identity verification and re-verification through Entrust, rendered with the Onfido SDK.\n\nEntrust provides identity verification sessions; the capture experience itself is\nthe Onfido web SDK (`onfido-sdk-ui`), rendered inside an application modal.\n\n== Services\n\n[cols=\"2,3\"]\n|===\n| `EntrustIdentityService` | Creates a verification session against the API.\n| `EntrustIdentityLauncherService` | Orchestrates: track → create session → open modal → track outcome.\n| `EntrustVerificationModalComponent` | Hosts the Onfido SDK.\n|===\n\n== Launching a verification\n\n[source,typescript]\n----\nlaunch(supplierKey: string): Observable<EntrustVerificationResult> {\n    this.eventLogger.track({ eventMessage: IdentityVerificationEventType.VerificationStarted });\n\n    return this.entrustIdentityService.createSession(supplierKey).pipe(\n        catchError(() => {\n            this.eventLogger.track({ eventMessage: IdentityVerificationEventType.VerificationFailed });\n            return of<EntrustSessionResponse>(undefined);          // <1>\n        }),\n        switchMap(session => this.openVerificationModal(session)),\n    );\n}\n\nprivate openVerificationModal(session: EntrustSessionResponse): Observable<EntrustVerificationResult> {\n    if (!session?.sdk_token || !session?.workflow_run_id) {\n        return of(EntrustVerificationResult.Error);                // <2>\n    }\n\n    return this.dialog.open(EntrustVerificationModalComponent, {\n        data: { sdkToken: session.sdk_token, workflowRunId: session.workflow_run_id },\n        width: '640px',\n        maxWidth: '95vw',\n    }).afterClosed().pipe(\n        take(1),\n        tap(result => {\n            if (result === EntrustVerificationResult.Completed) {\n                this.eventLogger.track({ eventMessage: IdentityVerificationEventType.VerificationCompleted });\n            }\n        }),\n    );\n}\n----\n<1> A failed session creation is *swallowed* into `undefined` after tracking, so\n    the stream continues rather than erroring.\n<2> Which then falls through to `EntrustVerificationResult.Error` — the caller\n    always receives a result, never an error notification.\n\nA session is only usable when it carries both `sdk_token` and `workflow_run_id`.\n\n== Session shape\n\n[source,typescript]\n----\n// core/pyb/interfaces/entrust-identity.interface.ts\ninterface EntrustSessionResponse {\n    sdk_token: string;        // Onfido SDK token\n    workflow_run_id: string;  // Entrust workflow run\n}\n----\n\n== Results\n\n`EntrustVerificationResult` — `Completed` or `Error` (plus whatever the modal\nreturns on dismissal). Only `Completed` emits the completion event.\n\n== Entry points\n\n[cols=\"2,3\"]\n|===\n| Seller Card transition | `SellerCardTransitionModalComponent` launches Entrust on accept when the identity document has expired. See xref:business/seller-card.adoc[Seller Card].\n| Manage Account | Identity re-verification from the ID section.\n|===\n\nThe Seller Card path is the important one:\n\n[source,typescript]\n----\nthis.store.pipe(select(selectCurrentUser), take(1)).subscribe(user => {\n    if (user?.id) { this.entrustLauncher.launch(user.id).subscribe(); }\n});\n----\n\nAn expired identity document blocks card migration, so accepting the transition\ndialog immediately starts re-verification.\n\n== Analytics\n\n[cols=\"2,2\"]\n|===\n| Constant | Segment event\n\n| `IdentityVerificationEventType.VerificationStarted` | `IdentityVerificationStarted`\n| `IdentityVerificationEventType.VerificationCompleted` | `IdentityVerificationCompleted`\n| `IdentityVerificationEventType.VerificationFailed` | `IdentityVerificationFailed`\n|===\n\n[NOTE]\n====\n`VerificationFailed` is only emitted when *session creation* fails. A supplier who\nopens the modal and abandons it, or whose document is rejected by Onfido, produces\n`VerificationStarted` with no terminal event. Funnel analysis must account for\nthat gap.\n====\n\n== First-time ID upload\n\nDistinct from Entrust re-verification: `/manage-account/your-information/upload-id`\n(`UploadIdModule` + `UploadIdService`) takes a direct document upload using\n`image-upload` / `image-preview` and `FileHelperService`. Entrust is for\n*re-verification* of an expired or rejected document.\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/recaptcha.adoc","title":"reCAPTCHA","description":"Invisible reCAPTCHA v3 on the authentication forms.","url":"https://docx.payability.com/supplier-client/integrations/recaptcha.html","navOrder":49,"source":"= reCAPTCHA\n:description: Invisible reCAPTCHA v3 on the authentication forms.\n\nGoogle reCAPTCHA v3 protects the unauthenticated forms. It is invisible — there is\nno challenge to solve; a score-bearing token is produced on submit and verified\nserver-side.\n\n== Configuration\n\nProvided by `AuthModule`, not `AppModule`, because only auth forms use it:\n\n[source,typescript]\n----\n{ provide: RECAPTCHA_V3_SITE_KEY, useValue: environment.recaptchaKey }\n----\n\n`RecaptchaV3Module` from `ng-recaptcha` is imported by `AuthModule`.\n\n== The badge component\n\n`src/app/core/pyb/components/recaptcha/recaptcha.compontent.ts` — declared in\n`AuthModule` as `RecaptchaComponent`, selector `pyb-recaptcha`.\n\nIt renders nothing (`template: ''`). Its only job is controlling the visibility of\nGoogle's floating badge, which reCAPTCHA injects into the document body outside\nAngular's control:\n\n[source,typescript]\n----\nngAfterViewInit(): void {\n    this.show();\n    this.h = setTimeout(() => this.show(), 2000);   // <1>\n}\n\nngOnDestroy(): void {\n    if (this.h) { clearTimeout(this.h); }\n    if (this.document.querySelector('.grecaptcha-badge')) {\n        this.renderer.setStyle(this.document.querySelector('.grecaptcha-badge'), 'visibility', 'hidden');   // <2>\n    }\n}\n\nprivate show(): void {\n    if (this.document.querySelector('.grecaptcha-badge')) {\n        this.renderer.setStyle(this.document.querySelector('.grecaptcha-badge'), 'visibility', 'visible');\n    }\n}\n----\n<1> The badge is injected asynchronously, so a single call on view-init often runs\n    before it exists. The 2 s retry covers that race.\n<2> On destroy the badge is hidden again, so it does not follow the user into the\n    authenticated application.\n\n[NOTE]\n====\nThe filename is misspelled — `recaptcha.compontent.ts`. Search for the class name\n`RecaptchaComponent` rather than the file.\n====\n\n[WARNING]\n====\nBoth attempts are best-effort. If the badge has still not been injected after two\nseconds it stays hidden, and Google's terms require the badge or an equivalent\nattribution to be visible on protected forms.\n====\n\n== Protected forms\n\n[cols=\"2,3\"]\n|===\n| Login | `AuthService.login(email, password, recaptchaToken)` sends the token as a `recaptcha` header alongside Basic auth.\n| Registration | Token passed with the account-creation request.\n| Forgot password | `AuthService.requestPassword(email, recaptcha)` sends a `recaptcha` header.\n|===\n\n[source,typescript]\n----\nlogin(email: string, password: string, recaptchaToken: string = ''): Observable<AccessData> {\n    const timezone = encodeURIComponent(this.timezone);\n    return this.http.get<AccessData>(`${this.env.authUrl}/token?timezon=${timezone}`, {\n        headers: {\n            Authorization: 'Basic ' + btoa(email + ':' + password),\n            recaptcha: recaptchaToken,\n        },\n    });\n}\n----\n\nThe token defaults to an empty string, so a call made without executing reCAPTCHA\nstill reaches the server — the backend decides whether to reject it.\n\n== Obtaining a token\n\nComponents inject `ReCaptchaV3Service` and execute an action on submit:\n\n[source,typescript]\n----\nthis.recaptchaV3Service.execute('login').subscribe(token => {\n    this.store.dispatch(/* … login with token … */);\n});\n----\n\nTokens are single-use and short-lived, so they must be generated per submission,\nnot cached.\n"},{"component":"supplier-client","module":"ROOT","path":"integrations/observability.adoc","title":"Observability","description":"CloudWatch metrics, page-load timing, session replay, and error surfacing.","url":"https://docx.payability.com/supplier-client/integrations/observability.html","navOrder":50,"source":"= Observability\n:description: CloudWatch metrics, page-load timing, session replay, and error surfacing.\n\n== AWS CloudWatch\n\n`src/app/vendors/cloud-watch/` — a thin wrapper that PUTs metric payloads to a\nPayability-hosted endpoint, which forwards them to CloudWatch.\n\n[source,typescript]\n----\n@Injectable({ providedIn: 'root' })\nexport class CloudWatchService {\n    constructor(@Inject(CLOUD_WATCH_API_URL) private readonly apiUrl: string, private readonly http: HttpClient) {}\n\n    putMetricData(metric: CloudWatchMetric): Observable<PutMetricDataResponse> {\n        return this.http.put<PutMetricDataResponse>(this.apiUrl, metric);\n    }\n}\n----\n\nConfigured with `CloudWatchModule.forRoot(environment.cloudWatchApiUrl)`.\n\n=== Payload shape\n\n[source,typescript]\n----\nexport interface CloudWatchMetric {\n    Namespace: MonitoringNamespace;\n    MetricData: MetricData[];\n}\n\nexport interface MetricData {\n    MetricName: MetricName;\n    Value: number | string;\n    Unit: MetricUnit;\n    Dimensions: Dimension[];\n    Timestamp: string;\n}\n----\n\nThis mirrors the AWS `PutMetricData` API shape, so the forwarding service is a\npass-through.\n\n=== Namespace and metrics\n\n[source,typescript]\n----\nexport enum MonitoringNamespace {\n    CriticalServiceMonitoring = 'critical_service_monitoring',\n}\n\nexport enum MetricName {\n    DailySummaryLoadTime  = 'load_time',\n    DailySummaryLoadError = 'load_error',\n}\n----\n\n[cols=\"2,3\"]\n|===\n| `load_time` | How long the tracked page took to become useful.\n| `load_error` | A Daily Summary data-load failure.\n|===\n\n`MetricUnit` covers the full AWS unit vocabulary — seconds through terabits per\nsecond, plus `Percent`, `Count` and `None`.\n\n[NOTE]\n====\nOnly one namespace and two metric names exist. The infrastructure is general —\nadding a metric means adding a `MetricName` member and a call site, not new\nplumbing.\n====\n\n== Page-load timing\n\n`PageLoadTimeTrackerModule.forRoot(['/dashboard'])` in `AppModule`.\n\n[cols=\"2,3\"]\n|===\n| `PageLoadTimeTrackerService` | Measures navigation-to-render duration for allow-listed routes.\n| `PageLoadTimeNotifierService` | Emits completed measurements.\n| `page-load-time-initializer.factory.ts` | `initializePageLodTimeTracking` bridges the notifier to `CloudWatchService`.\n| `page-load-time-tracker.token.ts` | DI token for the route allow-list.\n|===\n\nOnly `/dashboard` is measured today. Registered as an `APP_INITIALIZER` so\ntracking is live before the first navigation — see\nxref:architecture/bootstrap.adoc[Bootstrap & Initialization].\n\n== Daily Summary error tracking\n\n`initializeDailySummaryLoadDataErrorTrackerProvider` registers a listener that\nemits the `load_error` metric when the Daily Summary report fails to load. That\nreport is used by suppliers to reconcile daily payouts, so a data-pipeline break\nraises an operational alarm rather than only a UI error.\n\n== Smartlook\n\nSession replay, configured with `environment.smartLookKey` via `smartlook-client`.\nRecords interactions for UX analysis and support reproduction.\n\n[WARNING]\n====\nSession replay captures the DOM of a financial application. Any new field showing\nsensitive data — card numbers, tax ids, bank details — must be excluded from\nrecording.\n====\n\n== Segment\n\nProduct analytics, covered in xref:analytics/event-logger.adoc[Event Logger] and\nxref:analytics/event-catalog.adoc[Event Catalog].\n\n== Datadog\n\n`@datadog/browser-core`, `@datadog/browser-logs` and `@datadog/browser-rum` are\npresent in `devDependencies` but are not imported by the application. They are a\nleftover, not an active integration.\n\n== Error surfacing\n\n[cols=\"2,3\"]\n|===\n| `HttpErrorHandlerInterceptor` | Logs every HTTP failure to the console with `{ url, status, response }` and shows a toast for 400/403/500. See xref:security/interceptors.adoc[HTTP Interceptors].\n| `NotImplementedError` | `core/pyb/utils/errors.ts`. Thrown by `ReportBase` for unimplemented optional operations.\n| `PybToastService` | User-facing transient messages.\n| `BannerService` | Persistent, condition-driven messages.\n|===\n\n[NOTE]\n====\nThere is no global `ErrorHandler` override. Uncaught runtime errors surface only\nin the browser console — HTTP failures are the only category with structured\nhandling.\n====\n\n== Build provenance\n\n`environment.env`, `environment.version` and `environment.buildTime` are stamped\nat build time by `config/pre-build.js` and surfaced in the UI footer, so a\nscreenshot in a bug report identifies the exact build.\n\n`ServiceWorkerService` monitors for new versions and prompts a reload through\n`update-app-banner`.\n"},{"component":"supplier-client","module":"ROOT","path":"analytics/event-logger.adoc","title":"Event Logger","description":"EventLoggerService, the Segment wiring, user identification, and how to add an event.","url":"https://docx.payability.com/supplier-client/analytics/event-logger.html","navOrder":51,"source":"= Event Logger\n:description: EventLoggerService, the Segment wiring, user identification, and how to add an event.\n\nAll product analytics flow through one service. Components and effects never call\nthe Segment SDK directly.\n\n== `EventLoggerService`\n\n`src/app/core/pyb/services/event-logger.service.ts`\n\n[source,typescript]\n----\n@Injectable({ providedIn: 'root' })\nexport class EventLoggerService {\n    constructor(private readonly segment: SegmentService) {}\n\n    public identify(user: User) {\n        this.segment.identify(user.id, UserIdentificationModel.fromDto(user)).then();\n    }\n\n    public track(event: EventTypesInterface) {\n        console.log('Tracking: ', event);      // <1>\n        this.segment.track(event.eventMessage, event?.additionalProperties).then();\n    }\n}\n----\n<1> Every tracked event is logged to the console, in *all* environments including\n    production. Useful when verifying instrumentation; noisy otherwise.\n\n[NOTE]\n====\nBoth calls end in `.then()` with no handler. Failures in the Segment SDK resolve\nsilently and never surface — analytics never break a user flow, but a broken\nSegment key produces no visible symptom either.\n====\n\n== Event shape\n\n[source,typescript]\n----\nexport interface EventTypesInterface {\n    eventMessage: string;        // the Segment event name\n    additionalProperties?: any;  // optional properties object\n}\n----\n\n`eventMessage` should always come from an enum in\n`src/app/core/pyb/enums/event-types.enum.ts`, never a string literal.\n\n== Identification\n\n`identify()` is called from `AuthEffects.loadUser$`, immediately after the user is\nfetched and before `userLoaded` is dispatched:\n\n[source,typescript]\n----\nswitchMap(() => this.auth.getUserByToken()),\ntap(user => {\n    this.eventLogger.identify(user);\n    return user;\n}),\n----\n\nTraits sent to Segment:\n\n[source,typescript]\n----\nexport class UserIdentificationModel {\n    legalName: string;\n    firstName: string;\n    lastName: string;\n    email: string;\n    phone: string;\n    company: { id: string; name: string };\n\n    static fromDto(user: User): UserIdentificationModel {\n        const model = new UserIdentificationModel();\n        model.legalName = user.companyName;\n        model.firstName = user.firstName;\n        model.lastName  = user.lastName;\n        model.email     = user.email;\n        model.phone     = user.phone;\n        model.company   = { id: user.id, name: user.companyName };\n        return model;\n    }\n}\n----\n\nThe Segment user id is `user.id` — the supplier key.\n\n[WARNING]\n====\n`UserIdentificationModel` reads the *deprecated* flat fields on `User`\n(`companyName`, `firstName`, `lastName`, `phone`). If those are ever removed in\nfavour of `_personalInformation` / `_businessInformation`, this model must be\nmigrated at the same time or every identify call will send `undefined` traits.\n====\n\n[NOTE]\n====\nNo product-state traits (status, schema, permissions, product flags) are sent.\nSegment-side segmentation on those attributes is not possible with the current\npayload.\n====\n\n== Segment configuration\n\n[source,typescript]\n----\nSegmentModule.forRoot({\n    apiKey: environment.segmentKey,\n    debug: false,\n    loadOnInitialization: environment.production,   // <1>\n})\n----\n<1> Segment only auto-loads in production. Outside production the SDK is\n    configured but not loaded, so `track()` calls resolve without reaching\n    Segment. Verify instrumentation using the console log, not the Segment\n    debugger.\n\nAn additional pre-bundled helper, `src/eventLogger.bundle.min.js`, ships outside\nthe Angular build.\n\n== Emitting an event\n\n[source,typescript]\n----\nthis.eventLoggerService.track({\n    eventMessage: AdvanceLineEventTypes.FUNDS_WITHDRAWN,\n    additionalProperties: { offerType, amount },\n});\n----\n\nWithout properties:\n\n[source,typescript]\n----\nthis.eventLoggerService.track({ eventMessage: AirwallexCardEventTypes.CARD_BLOCKED });\n----\n\nTypical placement:\n\n[cols=\"2,3\"]\n|===\n| In an operation service | After the API call succeeds — `AirwallexCardBlockService` tracks inside `tap()`.\n| In a DocuSign handler | Around the confirmation and the business action.\n| In a guard | `HelpCenterRedirectGuard` tracks before redirecting.\n| In an effect | For store-driven events such as `trackBlogNavigate`.\n|===\n\n== Specialised trackers\n\n[cols=\"2,3\"]\n|===\n| `MarketplaceEventsService` | Emits `MarketplaceEventType` events for connection lifecycle changes.\n| `AmazonEventTrackerService` | Amazon Seller Central connection events.\n|===\n\nBoth delegate to `EventLoggerService`; they exist to keep event-shaping logic out\nof components.\n\n== Adding an event\n\n. Add the member to the appropriate enum in `event-types.enum.ts` — or create a\n  new enum if it is a new domain.\n. Inject `EventLoggerService`.\n. Call `track()` with the enum member and any properties.\n. Add the event to xref:analytics/event-catalog.adoc[Event Catalog].\n\n[IMPORTANT]\n====\nSegment event names are effectively permanent — renaming one breaks every\ndownstream dashboard and audience. Add a new member rather than editing an\nexisting value.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"analytics/event-catalog.adoc","title":"Event Catalog","description":"Every Segment event the client emits, its constant, its properties, and where it fires.","url":"https://docx.payability.com/supplier-client/analytics/event-catalog.html","navOrder":52,"source":"= Event Catalog\n:description: Every Segment event the client emits, its constant, its properties, and where it fires.\n\nSource of truth: `src/app/core/pyb/enums/event-types.enum.ts`. Emitted through\nxref:analytics/event-logger.adoc[`EventLoggerService`].\n\n== Advance Line\n\n[source,typescript]\n----\nexport enum AdvanceLineEventTypes {\n    OFFER_ACCEPTED        = 'AdvanceLineOfferAccepted',\n    OFFER_DECLINED        = 'AdvanceLineOfferDeclined',\n    OFFER_SAVED_FOR_LATER = 'AdvanceLineOfferSavedForLater',\n    DOCUSIGN_VERIFIED     = 'AdvanceLineDocusignVerified',\n    FUNDS_WITHDRAWN       = 'AdvanceLineFundsWithdrawn',\n    ADVANCE_REPORT_LINK   = 'ClickedOnAdvanceReportLink',\n}\n----\n\n[cols=\"2,2,2\"]\n|===\n| Event | Properties | Fires when\n\n| `AdvanceLineOfferAccepted` | — | An Advance Line offer is accepted.\n| `AdvanceLineOfferDeclined` | — | An offer is declined.\n| `AdvanceLineOfferSavedForLater` | — | An offer is saved for later.\n| `AdvanceLineDocusignVerified` | — | `confirmAdvanceLineDocuSignRequest` succeeds.\n| `AdvanceLineFundsWithdrawn` | `{ offerType, amount }` | A withdrawal completes, in `reloadCurrentState()`.\n| `ClickedOnAdvanceReportLink` | — | The advance report link is clicked.\n|===\n\n== Instant Advance\n\n[source,typescript]\n----\nexport enum InstantAdvanceEventTypes {\n    ADVANCE_REPORT_LINK = 'ClickedOnAdvanceReportLink',\n}\n----\n\n[WARNING]\n====\nThis resolves to the *same* Segment event name as\n`AdvanceLineEventTypes.ADVANCE_REPORT_LINK`. Segment cannot tell which product a\nreport-link click came from.\n====\n\n== Long Term Contract\n\n[source,typescript]\n----\nexport enum LongTermContractEventType {\n    OfferAccepted      = 'LongTermContractOfferAccepted',\n    OfferSavedForLater = 'LongTermContractOfferSaveForLater',\n    OfferDeclined      = 'LongTermContractOfferDeclined',\n    DocusignVerified   = 'LongTermContractDocusignVerified',\n}\n----\n\n[NOTE]\n====\n`OfferSavedForLater` maps to `LongTermContractOfferSave**For**Later` — \"Save\",\nnot \"Saved\", unlike the Advance Line equivalent. Both spellings are live.\n====\n\n== Airwallex cards\n\n[source,typescript]\n----\nexport enum AirwallexCardEventTypes {\n    CARD_DETAILS_VIEWED = 'AirwallexCardDetailsViewed',\n    CARD_BLOCKED        = 'AirwallexCardBlocked',\n    CARD_UNBLOCKED      = 'AirwallexCardUnblocked',\n    TERMS_ACCEPTED      = 'AirwallexTermsAccepted',\n}\n----\n\nEmitted by `AirwallexCardViewService`, `AirwallexCardBlockService`,\n`AirwallexCardUnblockService` and the terms modal.\n\n== Card ordering\n\n[source,typescript]\n----\nexport enum CardOrderEventTypes {\n    ORDER_STARTED = 'CardOrderStarted',\n    CARD_ORDERED  = 'CardOrdered',\n}\n----\n\nEmitted from the `/newcard` funnel.\n\n== Identity verification\n\n[source,typescript]\n----\nexport enum IdentityVerificationEventType {\n    VerificationStarted   = 'IdentityVerificationStarted',\n    VerificationCompleted = 'IdentityVerificationCompleted',\n    VerificationFailed    = 'IdentityVerificationFailed',\n}\n----\n\n[NOTE]\n====\n`VerificationFailed` fires only when *session creation* fails. Abandonment or a\nrejected document produces `VerificationStarted` with no terminal event.\n====\n\n== Marketplace lifecycle\n\n[source,typescript]\n----\nexport enum MarketplaceEventType {\n    Completed = 'Marketplace_Access_Completed',\n    Updated   = 'Marketplace_Access_Updated',\n    Deleted   = 'marketplace_deleted',\n    Remove    = 'Removed_Marketplace',\n    Added     = 'new_marketplace_added',\n}\n----\n\n[WARNING]\n====\nNaming is inconsistent across this enum — `Marketplace_Access_Completed` is\nTitle_Snake, `marketplace_deleted` is lowercase snake, `Removed_Marketplace` is\nTitle_Snake with reversed word order. All three are in use and cannot be\nnormalised without breaking dashboards.\n====\n\n`Deleted` and `Remove` are separate events for what appear to be the same user\naction; check the call site before relying on either.\n\n== Marketplace linking (onboarding)\n\n[source,typescript]\n----\nexport enum LinkMarketplaceEventType {\n    Loaded                    = 'Loaded Manage Marketplaces',\n    ShowMoreClicked           = 'Clicked_Onboarding_ChooseMarketplace_ShowMore',\n    ShowLessClicked           = 'Clicked_Onboarding_ChooseMarketplace_ShowLess',\n    ConnectAPIAmazonClicked   = 'Clicked_Onboarding_ConnectAPI_Amazon',\n    ManualAmazonClicked       = 'Clicked_Onboarding_ManualLink_Amazon',\n    AutoconnectWalmartClicked = 'Clicked_Onboarding_AutoConnect_Walmart',\n    ManualWalmartClicked      = 'Clicked_Onboarding_ManualLink_Walmart',\n    MarketplaceSubmitted      = 'Submitted Marketplace',\n    Completed                 = 'Completed_ChooseMarketplace',\n    chooseMarketplace         = 'Added Additional Marketplace',\n    ProductClicked            = 'Product Clicked',\n    ProductAdded              = 'Product Added',\n}\n----\n\n[cols=\"2,3\"]\n|===\n| `Loaded Manage Marketplaces` | The marketplace-linking step loads.\n| `Clicked_Onboarding_ChooseMarketplace_ShowMore` / `_ShowLess` | The catalog is expanded or collapsed.\n| `Clicked_Onboarding_ConnectAPI_Amazon` | API connection chosen for Amazon.\n| `Clicked_Onboarding_ManualLink_Amazon` | Manual link chosen for Amazon.\n| `Clicked_Onboarding_AutoConnect_Walmart` | Auto-connect chosen for Walmart.\n| `Clicked_Onboarding_ManualLink_Walmart` | Manual link chosen for Walmart.\n| `Submitted Marketplace` | A marketplace is added.\n| `Completed_ChooseMarketplace` | The linking step is completed.\n| `Added Additional Marketplace` | A further marketplace is added later.\n| `Product Clicked` / `Product Added` | Generic e-commerce-style events.\n|===\n\n== Invite Payability (onboarding)\n\n[source,typescript]\n----\nexport enum InviteMarketplaceEventType {\n    Loaded                     = 'Loaded_InvitePayability',\n    SkipClicked                = 'Clicked_Onboarding_InvitePayability_Skip',\n    ConfirmClicked             = 'Clicked_Onboarding_InvitePayability_MarketplaceLogin_Confirm',\n    GoToMarketplaceClicked     = 'Clicked_Onboarding_InvitePayability_GoToMarketplace_Link',\n    InviteSentClicked          = 'Clicked_Onboarding_InvitePayability_InviteSent_Confirm',\n    GrantPermissionClicked     = 'Clicked_Onboarding_InvitePayability_GrantPermissions_Confirm',\n    GrantPermissionFailClicked = 'Clicked_Onboarding_InvitePayability_GrantPermissions_Fail',\n    WalmartAPIClicked          = 'Clicked_Onboarding_InvitePayability_WalmartAPI',\n    PayoneerClicked            = 'Clicked_Onboarding_InvitePayability_Payoneer',\n    Completed                  = 'Completed_InvitePayability',\n}\n----\n\nThis is the most fully instrumented funnel in the application — it has a load\nevent, a skip event, per-step confirmations, an explicit failure event and a\ncompletion event.\n\n== Onboarding\n\n[source,typescript]\n----\nexport enum OnboardingEventType {\n    SubmittedApplication    = 'Submitted Application',\n    LoadedApplicationStatus = 'Loaded Application Status',\n}\n----\n\n== Cancellation workflow\n\n[source,typescript]\n----\nexport enum CancellationWorkflowEventType {\n    DeactivateAccountStartBtnClicked    = 'Clicked_Deactivate_Account_Button',\n    ReactivateAccountStartBtnClicked    = 'Clicked_Reactivate_Account_Button',\n    ReactivateAccountConfirmBtnClicked  = 'Clicked_Reactivate_Account_Confirm_Button',\n    SubmittingTheCancellationSurvey     = 'Submitting_The_Cancellation_Survey',\n}\n----\n\n[NOTE]\n====\nReactivation has both a start and a confirm event; deactivation has only a start\nevent. Use `Submitting_The_Cancellation_Survey` as the completion proxy for\ndeactivation.\n====\n\n== Cross-sell\n\n[source,typescript]\n----\nexport enum CrossSellEventType {\n    ClickedSellerCardSignUpOnProductOfferings               = 'Clicked Seller Card Sign Up on Product Offerings',\n    ClickedInstantTransferSignUpOnProductOfferings          = 'Clicked Instant Transfer Sign Up on Product Offerings',\n    ClickedInstantAdvanceCrossSellAppliedOnProductOfferings = 'Instant Advance on Product Cross-Sell Page Applied',\n}\n----\n\n== Navigation\n\n[source,typescript]\n----\nexport enum RouterNavigationEvents {\n    HelpCenterLinkClicked = 'Clicked_Help_Center_Link',\n}\n----\n\nEmitted by `HelpCenterRedirectGuard` with\n`additionalProperties: { userId }` — one of the few events that carries the user\nid explicitly, since it fires as the user leaves the application.\n\n== Blog\n\n[source,typescript]\n----\nexport enum BlogEvents {\n    CustomerBlogPostNavigate = 'Customer_Blog_Post_Navigate',\n}\n----\n\nEmitted from the `trackBlogNavigate` action in the `blog` slice.\n\n== Naming conventions in practice\n\nThere is no single convention. Four styles coexist:\n\n[cols=\"2,3\"]\n|===\n| PascalCase, no separator | `AdvanceLineFundsWithdrawn`, `AirwallexCardBlocked`\n| Title_Snake_Case | `Clicked_Onboarding_ConnectAPI_Amazon`, `Completed_InvitePayability`\n| Title Case With Spaces | `Submitted Application`, `Added Additional Marketplace`\n| lowercase_snake_case | `marketplace_deleted`, `new_marketplace_added`\n|===\n\nMatch the style of the enum you are adding to rather than introducing a fifth.\n\n== Events with properties\n\nOnly two events carry properties today:\n\n[cols=\"2,3\"]\n|===\n| `AdvanceLineFundsWithdrawn` | `{ offerType, amount }`\n| `Clicked_Help_Center_Link` | `{ userId }`\n|===\n\nEverything else relies on the identify traits — which do not include product\nstate. See xref:analytics/event-logger.adoc[Event Logger].\n"},{"component":"supplier-client","module":"ROOT","path":"reference/services.adoc","title":"Service Catalog","description":"Every service in core/pyb/services and the notable feature-scoped services, with what they do.","url":"https://docx.payability.com/supplier-client/reference/services.html","navOrder":53,"source":"= Service Catalog\n:description: Every service in core/pyb/services and the notable feature-scoped services, with what they do.\n\nServices in `src/app/core/pyb/services/` are root-provided\n(`@Injectable({ providedIn: 'root' })`) unless noted. Feature-scoped services are\nprovided by their own module.\n\n== Identity and permissions\n\n[cols=\"2,3\"]\n|===\n| `PermissionsService`\n| Calculates the full `PermissionsEnum[]` from the `User`. The single source of\n  feature gating. xref:security/permissions.adoc[Permissions System]\n\n| `UserService`\n| Reactive access to the current user. Subscribes once in its constructor,\n  caches `user`, `userId`, and decodes the JWT for `login_key` and\n  `tfa_verified`. Exposes `getCurrentUser()`, `getCurrentUserId()`,\n  `getUserLoginKey()`, `isTFAConfirmed()`, `isAprUserContract()` and `user$`.\n\n| `PersistentStateManager`\n| `localStorage` read/write/clear behind the persistent meta-reducer.\n  xref:state/persistence.adoc[State Persistence]\n|===\n\n[NOTE]\n====\n`UserService` reads the JWT with `jwtDecode` (`utils/jwt-decode.ts`) to extract\n`tfa_verified` — the 2FA challenge state lives in the token, not in the store.\n====\n\n== Contracts and products\n\n[cols=\"2,3\"]\n|===\n| `DocusignVerificationService` | All six DocuSign flows, redirect construction and the replay guard. xref:business/docusign.adoc[DocuSign Contract Flows]\n| `InstantTransferService` | Instant Transfer setup via TabaPay, fee calculation, error mapping. xref:business/instant-transfer.adoc[Instant Transfer]\n| `AirwallexCardService` | Airwallex and unified card HTTP surface. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n| `AdvanceLineActivationService` | Advance Line activation helpers (`advance-line-activation/`).\n| `AdvanceLineTooltipTextService` | Contextual tooltip copy for the Advance Line widget.\n| `ContractsOverviewTextService` | Descriptive copy for the contracts overview report.\n| `FeesScheduleHelperService` | Reads the `fees` slice; `getInstantTransferFees()`, `crateInstantTransferFeesData()`, `hasFreeAccessPaymentFee()`.\n| `SupplierTransferPermissionsService` | Transfer-permission checks (`supplier-transfer-permissions/`).\n| `PunchCardService` | Reward/progress punch-card logic (`punch-card/`).\n|===\n\n== Session-wide orchestration\n\n[cols=\"2,3\"]\n|===\n| `GlobalSubscriptionsService`\n| The single place session-scoped background behaviour is started. Idempotent via\n  an `initialized` flag. xref:architecture/overview.adoc[Architecture Overview]\n\n| `CancellationBannerInitializerService`\n| Builds the pending/reached cancellation banners from marketplace opt-out dates.\n  xref:business/cancellation.adoc[Cancellation & Reactivation]\n\n| `SuspensionReasonInitializerService`\n| Resolves the suspension reason to copy and opens the remediation modal.\n  xref:business/suspension.adoc[Suspension]\n\n| `TwoFactorSetupBannerInitializerService`\n| Shows the 2FA enrolment banner when the supplier has not enrolled.\n\n| `InternalMessagesService`\n| RxJS message bus for cross-component communication; also owns the\n  account-cancelled banner.\n|===\n\n== UI services\n\n[cols=\"2,3\"]\n|===\n| `BannerService`\n| Persistent banners. `showBanner()`, `deleteBanner()`, `getBanners()`,\n  `getDeleteBanners()`.\n\n| `BreakpointService`\n| Reactive responsive breakpoints, backed by `BreakpointsEnum`.\n\n| `DashboardPageHelperService`\n| Detects a supplier on the wrong environment host and rebuilds the URL to\n  redirect them, optionally with a session token.\n\n| `ExportService`\n| Opens the export progress dialog and the export form dialog.\n  xref:features/reports.adoc[Reports]\n\n| `NavigationService`\n| Named navigation helpers wrapping `Router`.\n\n| `RouteUrlHelperService`\n| Typed URL construction and `prevUrl` tracking (used by\n  `TwoFactorAuthenticationGuard`).\n\n| `HyperlinkService`\n| Turns plain text into safe HTML links. `addHyperlink()`, `hyperlinkEmails()`,\n  and phone linking with `PhonePattern` formats. Default CSS class injected via\n  `HYPER_LINK_CONFIG_TOKEN`.\n\n| `SupportHelperService`\n| Resolves contextual help articles from `help-articles.const.ts`.\n\n| `FeedbackService`\n| Opens `SwitchCdDialogComponent` (checking/deposit switch), with an optional\n  `revert` flag.\n\n| `ClientProviderService`\n| White-label / theme resolution (`service-provider/`). Run as an\n  `APP_INITIALIZER`.\n|===\n\n[WARNING]\n.`BannerService.showBanner` is delayed by one second\n====\n[source,typescript]\n----\nshowBanner(banner: Banner) {\n    setTimeout(() => this.bannerSubject.next(banner), 1000);\n}\n----\n\nThe delay lets the shell finish rendering before a banner appears. It also means\na banner shown and immediately deleted may still appear, since the delete is not\nqueued.\n====\n\n== Analytics\n\n[cols=\"2,3\"]\n|===\n| `EventLoggerService` | The Segment wrapper. `identify()` and `track()`. xref:analytics/event-logger.adoc[Event Logger]\n| `MarketplaceEventsService` | Emits `MarketplaceEventType` events.\n| `AmazonEventTrackerService` | Amazon Seller Central connection events.\n| `CloudWatchService` | `putMetricData()` (in `vendors/cloud-watch/`). xref:integrations/observability.adoc[Observability]\n|===\n\n== Identity verification\n\n[cols=\"2,3\"]\n|===\n| `EntrustIdentityService` | Creates an Entrust verification session.\n| `EntrustIdentityLauncherService` | Orchestrates the session, modal and tracking. xref:integrations/entrust.adoc[Entrust Identity]\n|===\n\n== Infrastructure\n\n[cols=\"2,3\"]\n|===\n| `LocalStorageService` | Typed `localStorage` wrapper with serialization.\n| `OAuthTokenService` | Holds the Plaid OAuth return URL and link token. xref:integrations/plaid.adoc[Plaid]\n| `ServiceWorkerService` | PWA update detection and reload prompt.\n| `FormHelpersService` | Reactive-form helpers — mark-all-touched, reset, common `FormGroup` builders.\n| `FileHelperService` | File reading as base64, type and size validation.\n| `HttpErrorHandlerInterceptor` | Error toasts and special-case handling (`http-error-handler/`). xref:security/interceptors.adoc[HTTP Interceptors]\n| `BlogService` | CMS blog access (`blog/`).\n|===\n\n== Widget services\n\n`core/pyb/services/widgets/`\n\n[cols=\"2,3\"]\n|===\n| `product-summary/product-summary.service.ts` | Product summary data for the dashboard panel.\n| `product-summary/product-summary-grid.service.ts` | Grid configuration for that panel.\n| `instant-access/` | Instant Access widget logic.\n| `seller-card/` | Seller Card widget logic.\n|===\n\n== Modal services\n\n[cols=\"2,3\"]\n|===\n| `MessageModalService` (`@pyb-modals/message-modal`) | `openMessageModal()`, `openSuccessModal()` — the standard result dialogs used across every flow.\n| `TwoFactorAuthenticationModalService` | Opens the SMS challenge dialog.\n| `BankVerificationInfoModalService` | Plaid bank-verification explainer; `check(plaidConfig)`.\n| `CancellationSurveyShowWhenDeactivatingAccountService` | Shows the survey during deactivation.\n| `services/modals/cancellation-survey/` | Cancellation survey modal services.\n|===\n\n== Feature-scoped services\n\nProvided by their own module rather than root.\n\n[cols=\"2,3\"]\n|===\n| `ReportsService` | Reports shell state (`ReportsModule`).\n| `SupplierKeyProviderService` | Supplies the supplier key to report data services.\n| `ManageAccountService` | Manage Account tab state (`ManageAccountModule`).\n| `ProductOfferingsService` | Cross-sell availability and offer configuration.\n| `SendMoneyPageService` | Vendor Pay send-money submission.\n| `PartnersService` | Partner catalog and filtering.\n| `ReferralService` | Builds the personalised referral link.\n| `UploadIdService` | ID document upload.\n| `CancellationWorkflowService` | Deactivation and reactivation (root-provided but feature-specific).\n| `TopBarService` | Onboarding pre-activation status bar (root-provided).\n| `VendorpayReportsService` / `VendorpayReportsMappingService` | Vendor Pay report data and mapping (one pair per report).\n| `AirwallexCardBlockService` / `…UnblockService` / `…ViewService` | Airwallex card operations.\n| `SellerCardBlockService` / `…UnblockService` / `…ViewService` / `SellerCardActivateService` | Marqeta card operations.\n|===\n\n== Flows\n\n`core/pyb/flows/` — see xref:architecture/flows-and-tasks.adoc[Flows & Tasks Engine].\n\n[cols=\"2,3\"]\n|===\n| `AdvanceLineWithdrawFlow` | Draw funds.\n| `AdvanceLineAcceptOfferFlow` | Accept an offer.\n| `AdvanceLinePendingActivationFlow` | Expedited first draw.\n| `AdvanceLineLegacyActivationFlow` | Legacy product activation.\n| `LtcFlowService` | New Long Term Contract.\n| `LtcRenewalFlowService` | LTC renewal, with a change-contract fallback.\n| `ReSignContractFlow` | Re-sign an updated agreement.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"reference/enums.adoc","title":"Enum Reference","description":"Every domain enum, its values, and where it is used.","url":"https://docx.payability.com/supplier-client/reference/enums.html","navOrder":54,"source":"= Enum Reference\n:description: Every domain enum, its values, and where it is used.\n\nEnums live in `src/app/core/pyb/enums/` unless another path is given. Enums\nimported from `@payability/pyb-ng2-api` are listed at the end.\n\n== Permissions\n\n`permissions.enum.ts` — a *numeric* enum. See\nxref:security/permissions.adoc[Permissions System] for the full list and the\nconditions behind each member.\n\n[WARNING]\n====\nValues are positional. Always append; never reorder or insert.\n====\n\n== Onboarding\n\n[source,typescript]\n----\n// onboarding-schema.enum.ts\nexport enum OnboardingSchemaEnum {\n    Base            = '3.0',\n    WithPlaid       = '3.0_plaid',\n    Expedited       = 'exp_onb',\n    AmazonExpedited = 'amzn_exp_onb',\n    Next            = 'NEXT',\n}\n\n// onboarding-template-code.enum.ts\nexport enum OnboardingTemplateCode {\n    FreeAccess = 'free_access_3.0',\n}\n----\n\n== Status\n\n[source,typescript]\n----\n// access-status.enum.ts — compliance record status\nexport enum AccessStatusEnum {\n    Pending = 'pending', Incomplete = 'incomplete', Rejected = 'rejected',\n    Completed = 'completed', Verified = 'verified',\n}\n\n// api-status.enum.ts — marketplace API connection status\nexport enum ApiStatus {\n    Needed = 'needed', Required = 'required', Rejected = 'rejected',\n    Confirmed = 'confirmed', InComplete = 'incomplete', InReview = 'in-review', Pending = 'pending',\n}\n----\n\nOnly `Required` and `Needed` trigger a reconnect prompt — see\nxref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\n[source,typescript]\n----\n// core/auth/_models/status.model.ts\nexport enum SupplierDashboardStatusType {\n    Onboarding = 'onboarding', Welcome = 'welcome',\n    InactiveDashboard = 'inactive_dashboard', Active = 'active',\n}\n\nexport enum CancelationStatus { Canceled = 'canceled', Pending = 'pending' }\n----\n\n== Suspension\n\n[source,typescript]\n----\nexport enum SuspensionReason {\n    AccountAtRisk                   = 'sh.susp:acc_at_risk',\n    ApiNeeded                       = 'sh.susp:api_needed',\n    Cancellation                    = 'sh.susp:cancellation',\n    InvalidCreditCard               = 'sh.susp:inv_credit_card',\n    MarketplaceAccessDiverted       = 'sh.susp:access_diverted',\n    MarketplacePayeeDiverted        = 'sh.susp:payee_diverted',\n    MarketplacePermissionsIncorrect = 'sh.susp:perm_inc',\n    SellingPrivilegesRemoved        = 'sh.susp:sell_prv_rmv',\n    SuspendedByMarketplace          = 'sh.susp:susp_by_mp',\n    FailedPayment                   = 'sh.susp:failed_payments',\n}\n----\n\nThe `sh.susp:` prefix is stripped when resolving translation keys. See\nxref:business/suspension.adoc[Suspension].\n\n== Marketplaces and compliance\n\n`marketplaces.enum.ts` (`MarketplaceEnum`) and `compliance.enum.ts` hold fixed\nUUIDs — see xref:business/marketplaces.adoc[Marketplaces & Compliance] for the\nfull tables.\n\n`compliance.enum.ts` contains: `PayabilityComplianceEnum`,\n`DoctypeComplianceEnum`, `WalmartComplianceTypes`, `WalmartComplianceKeys`,\n`PayoneerComplianceKeys`, `AmazonComplianceKeys`, `EbayComplianceKeys`,\n`NeweggComplianceKeys`, `NeweggVendorsComplianceKeys`,\n`NeweggComplianceActionKeys`, `AuthorizeComplianceKeys`, `ShopifyComplianceKeys`,\n`EtsyComplianceKeys`, `PaymentGatewaysKeys` and `MpPermissionEnum`.\n\n[source,typescript]\n----\nexport enum MpPermissionEnum {\n    APPROVED = 'approved', VERIFIED = 'verified',\n    PERM_APPROVED = 'perm-approved', PERM_PENDING = 'perm-pending', PERM_REJECTED = 'perm-rejected',\n    PENDING = 'pending', REJECTED = 'rejected', INCOMPLETE = 'incomplete', DISABLED = 'disabled',\n    SALES_RESOLUTION = 'sales resolution', PERM_SALES_RESOLUTION = 'perm-sales resolution',\n}\n----\n\n== Cards\n\n[source,typescript]\n----\n// card-provider.enum.ts\nexport enum CardProvider { Marqeta = 'marqeta', Airwallex = 'airwallex' }\n\n// card-form-factor.enum.ts\nexport enum CardFormFactor { Physical = 'PHYSICAL', Virtual = 'VIRTUAL' }\n\n// airwallex-card-status.enum.ts\nexport enum AirwallexCardStatus { Active = 'ACTIVE', Inactive = 'INACTIVE', Closed = 'CLOSED', Pending = 'PENDING' }\n\n// core/pyb/interfaces/unified-supplier-card.interface.ts\nexport enum UnifiedCardStatus { Active = 'ACTIVE', Blocked = 'BLOCKED', Pending = 'PENDING', Closed = 'CLOSED' }\n----\n\n`PCardStatus` and `PCardTypeIndicator` (Marqeta) live in\n`core/_base/interfaces/`.\n\n=== Network response codes\n\n`network-response-code.enum.ts` maps card decline codes to named constants:\n\n[cols=\"1,3\"]\n|===\n| `01` | Refer to card issuer\n| `04` | Pick up\n| `05` | Do not honor\n| `07` | Pick up card, special conditions\n| `12` | Invalid transaction\n| `14` | Invalid card number, no such number\n| `33` | Expired card, pick up\n| `41` | Lost card, pick up\n| `43` | Stolen card, pick up\n| `46` | Closed account — do not reattempt with the same PAN or token\n| `51` | Insufficient funds\n| `52` / `53` | No checking / no savings account\n| `54` | Expired card\n| `57` | Transaction not permitted to cardholder\n| `59` | Suspected fraud\n| `61` | Exceeds withdrawal amount limit\n| `63` | Security violation (may also be a chargeback)\n| `65` | Exceeds withdrawal frequency limit\n| `67` | Hard capture, pick up\n| `75` | Allowable number of PIN tries exceeded\n| `78` | Customer not eligible for POS\n| `89` | CVV verification failed\n| `100` | Do not honor (ISO)\n| `P07` | Account blocked\n| `P11` | Transaction forbidden on this account\n| `S2` | Check deposit limit exceeded\n| `S9` | Expiration date mismatch\n| `SN` | Daily limit exceeded\n| `ZD` | Mastercard Send — error due to expiration date\n| `ZN` | Mastercard Send — decline, card was declined\n| `ZR` | Mastercard Send — decline, unsupported card\n|===\n\n== DocuSign\n\n[source,typescript]\n----\n// core/pyb/models/docusign-parameters.model.ts\nexport enum DocusignEventEnum { SingingComplete = 'signing_complete', Cancel = 'cancel', Decline = 'decline' }\n\nexport enum DocusignFlowEnum {\n    AdvanceLineActive = 'advance-line-active', LongTermContract = 'long-term-contract',\n    AdvanceLinePending = 'advance-line-pending', InstantAdvanceActive = 'instant-advance-active',\n    ReAssignContract = 're-assign-contract', OnboardingActivation = 'onboarding-activation',\n}\n\nexport enum DocusignModeEnum { Dashboard = 'dashboard', ProductOfferingsList = 'product-offerings-list' }\n----\n\n== Long Term Contract\n\n[source,typescript]\n----\n// core/pyb/constants/ltc-flow.constants.ts\nexport enum LtcContractActionType { New = 'new', Renew = 'renew', Change = 'change' }\n----\n\n== Notifications and CTAs\n\n`features/dashboard-notifications/dashboard-notificatons.enums.ts` holds\n`DashboardNotificationsActionTypes`, `DashboardNotificationAction` and\n`DashboardNotificationType`. See\nxref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n\n[source,typescript]\n----\n// cta-action-types.enum.ts\nexport enum CtaActionTypesEnum { Accept = 'accept', Decline = 'decline', SaveForLater = 'save' }\n----\n\n== Two-factor authentication\n\n[source,typescript]\n----\n// features/two-factor-authentication/two-factor-authentication.enums.ts\nexport enum TwoFactorAuthenticationSteps {\n    RegisterPhoneNumber = 'register', SendSms = 'sendSms', VerifyCode = 'test',\n}\n----\n\nPlus `TwoFactorAuthenticationActionTypes`.\n\n== Errors\n\n[source,typescript]\n----\n// http-errors.enum.ts\nexport enum HttpErrorsEnum {\n    BAD_REQUEST = 400, UNAUTHORIZED = 401, FORBIDDEN = 403, INTERNAL_SERVER_ERROR = 500,\n}\n\n// auth-errors.enum.ts\nexport enum AuthErrorsEnum {\n    EmailNotFound           = 'AUTH.VALIDATION.NOT_FOUND',\n    InvalidVerificationCode = 'AUTH.FORGOT.INVALID_RESET_PASSWORD_VERIFICATION_CODE',\n    Fail                    = 'AUTH.FORGOT.FAIL',\n}\n----\n\n`AuthErrorsEnum` values are *translation keys*, not messages.\n\n== Presentation\n\n[source,typescript]\n----\n// banner-types.enum.ts\nexport enum BannerTypes { INFO = 'info', WARNING = 'warning', DANGER = 'danger', SUCCESS = 'success', ANNOUNCEMENT = 'announcement' }\n\n// date-formats.enum.ts\nexport enum DateFormatsEnum { Default = 'MM/dd/yyyy', ShortTime = 'shortTime', MonthDate = 'MMM d', LongDate = 'MMMM d, y' }\n\n// breakpoints.enum.ts\nexport enum BreakpointsEnum {\n    XSmall = '(max-width: 360.99px)',\n    LSmall = '(min-width: 361px) and (max-width: 576.99px)',\n    Small  = '(min-width: 577px) and (max-width: 768.99px)',\n    Medium = '(min-width: 769px) and (max-width: 1024.99px)',\n    Large  = '(min-width: 1025px) and (max-width: 1399.99px)',\n    XLarge = '(min-width: 1400px)',\n}\n----\n\n== Forms and pages\n\n[source,typescript]\n----\n// cancellation-survey-form-fields-types.enum.ts\nexport enum CancellationSurveyFormFieldsTypes { REASON = 'reasonCode', COMMENT = 'comment', RECOMMENDATION = 'rating' }\n\n// views/pages/reports/reports.config.ts\nexport enum ReportsFilter    { DateFormat = 'yyyy-MM-dd' }\nexport enum ReportsPageSizes { Desktop = 50, Mobile = 6, DailySummaryMobile = 4 }\n\n// views/pages/reports/reports.enums.ts\nexport enum ReportActionEnum { ExportAll, ExportCSV }\n\n// views/pages/partners/partners.enum.ts\nexport enum PartnerTypeEnum { All = 0, Marketplaces = 1, EcommerceTools = 2, Shipping = 3, SolutionProviders = 4 }\n\n// views/pages/product-offerings/product-offering-mode.enum.ts\nexport enum ProductOfferingModeEnum { DEFAULT = 'DEFAULT', CAN_REFINANCE = 'CAN_REFINANCE', REFINANCING_PROCESSING = 'REFINANCING_PROCESSING' }\n\n// views/pages/manage-account/cancellation-workflow/cancellation-workflow.enum.ts\nexport enum CancellationWorkflowOptionAvailableEnum { DEACTIVATE = 'DEACTIVATE', REACTIVATE = 'REACTIVATE', NONE = 'NONE' }\n----\n\n== Flows\n\n[source,typescript]\n----\n// core/_base/tasks-flow/tasks-flow.ts\nexport enum FlowTaskResultType { Done = 'done', Cancel = 'cancel', Terminated = 'terminated', Error = 'error' }\n----\n\n== Plaid\n\n[source,typescript]\n----\n// vendors/plaid/plaid.interface.ts\nexport enum PlaidProducts     { Transactions = 'transactions', Auth = 'auth', Identity = 'identity', Assets = 'assets' }\nexport enum PlaidEnv          { Production = 'production', Development = 'development', Sandbox = 'sandbox' }\nexport enum PlaidCountryCodes { Us = 'US' }\n----\n\n== CloudWatch\n\n[source,typescript]\n----\n// vendors/cloud-watch/cloud-watch-metric.interface.ts\nexport enum MonitoringNamespace { CriticalServiceMonitoring = 'critical_service_monitoring' }\nexport enum MetricName { DailySummaryLoadTime = 'load_time', DailySummaryLoadError = 'load_error' }\nexport enum MetricUnit { /* the full AWS unit vocabulary */ }\n----\n\n== Analytics\n\n`event-types.enum.ts` holds every Segment event constant — see\nxref:analytics/event-catalog.adoc[Event Catalog].\n\n== Imported from `@payability/pyb-ng2-api`\n\n[cols=\"2,3\"]\n|===\n| `SupplierStatusType` | `Active`, `Pending`, `PendingActivation`, `Suspended`.\n| `SupplierDashboardStatusTypeV2` | Server-side dashboard status (v2).\n| `AdvanceLineStatusEnum` | Advance Line status.\n| `AdvanceLineUWStatusEnum` | Underwriting status.\n| `AdvanceLineModeEnum` | `Legacy` versus the modern Advance Line.\n| `AdvanceLineOfferTypeEnum` | Offer/fee structure.\n| `SignAdvanceContractEnum` | APR contract signature requirement.\n| `DocusignEventTypeEnum` | DocuSign events (mirrors the local `DocusignEventEnum`).\n| `CrossSellProductEnum` | `SellerCard`, `InstantTransfer`, `InstantAdvance`, `AccessPlus`.\n| `CrossSellAvailabilityEnum` | Including `CannotRequest`.\n| `PaymentMethodType` | Including `InstantTransfer`.\n| `PreActivationStatusStepsKeys` | Onboarding pre-activation steps.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"reference/models.adoc","title":"Model Reference","description":"Client-side domain models, their fields, and the DTO mappers that build them.","url":"https://docx.payability.com/supplier-client/reference/models.html","navOrder":55,"source":"= Model Reference\n:description: Client-side domain models, their fields, and the DTO mappers that build them.\n\nModels are plain classes. Those built from an API DTO expose a static\n`fromDto()`; those bound to forms expose `clear()` and often a static\n`requiredFields` list used by validation.\n\n== `BaseModel`\n\n`src/app/core/_base/crud/`\n\n[source,typescript]\n----\nexport class BaseModel {\n    _isEditMode = false;\n    _userId = 0;\n    _createdDate: string;\n    _updatedDate: string;\n}\n----\n\nInherited by `User` and `Permission`. The fields are template scaffolding and are\nnot used by supplier flows.\n\n== `User`\n\n`core/auth/_models/user.model.ts`. Full field table:\nxref:business/domain-model.adoc[Domain Model].\n\nBuilt by `AuthService.prepareUser(supplier: SupplierDto, token: string)`.\n`clear()` resets every field, including nested objects, and is called before\nmapping.\n\n[WARNING]\n====\n`clear()` assigns random placeholder tokens:\n`this.accessToken = 'access-token-' + Math.random()`. A `User` that has been\ncleared but not re-mapped carries a meaningless token.\n====\n\n== `Status`\n\n`core/auth/_models/status.model.ts` — around 40 supplier state flags, built by\n`Status.fromDto(dto: SupplierStatusDto)`. Full field tables:\nxref:business/domain-model.adoc[Domain Model].\n\n[NOTE]\n====\n`fromDto` copies fields explicitly rather than spreading. A field added to\n`SupplierStatusDto` does not appear on `Status` until it is added to both the\ninterface and the mapper.\n====\n\n`AuthService.getUserByToken()` merges the status differently — it iterates the\nexisting keys of `user.status` and copies matching keys off the DTO:\n\n[source,typescript]\n----\nObject.keys(user.status).forEach(key => {\n    if (status.hasOwnProperty(key)) { user.status[key] = status[key]; }\n});\n----\n\nSince `clear()` initialises every field to `null`, the key set is complete.\n\n== `Address`\n\n[source,typescript]\n----\nexport class Address {\n    static readonly requiredFields: (keyof Address)[] = ['addressLine1', 'city', 'country', 'postCode', 'state'];\n    addressLine1?: string;\n    addressLine2?: string;\n    city?: string;\n    state?: string;\n    postCode?: string;\n    country?: string;\n    clear?(): void;\n}\n----\n\n`country` is an ISO code; the Seller Card US test compares it against\n`CountryCode.US`.\n\n=== `BeneficiaryAddress`\n\nThe API-shaped variant used for payouts, with a mapper from `Address`:\n\n[source,typescript]\n----\nexport class BeneficiaryAddress {\n    addr1: string; addr2?: string; addr3?: string;\n    country: string; city: string; state: string; postalCode: string;\n\n    static fromAddress(address: Address): BeneficiaryAddress { /* … */ }\n}\n----\n\n[NOTE]\n====\nField naming differs between the two: `addressLine1` / `postCode` on `Address`\nversus `addr1` / `postalCode` on `BeneficiaryAddress`. Always convert with\n`fromAddress()` rather than passing an `Address` where a beneficiary address is\nexpected.\n====\n\n== `PersonalInformation`\n\n[source,typescript]\n----\nexport class PersonalInformation {\n    static readonly requiredFields: (keyof PersonalInformation)[] =\n        ['firstName', 'lastName', 'phone', 'dateOfBirth', 'taxId'];\n    firstName?: string;\n    lastName?: string;\n    residentialAddress?: Address;\n    dateOfBirth?: string;\n    phone?: string;\n    email?: string;\n    taxId?: string;\n}\n----\n\n`residentialAddress` is not in `requiredFields`, but it *is* required for Seller\nCard eligibility — the two notions of \"required\" are independent.\n\n== `BusinessInformation`\n\n[source,typescript]\n----\nexport class BusinessInformation {\n    static readonly requiredFields: (keyof BusinessInformation)[] = ['legalName', 'startDate', 'address'];\n    legalName: string;\n    incorporatedInTheUS: boolean;\n    stateOfIncorporation: string;\n    businessTypeCode: string;\n    ownerSSN: string;\n    address: Address;\n    ein: string;\n    website: string;\n    startDate: Date;\n}\n----\n\n`ein` is mapped from `SupplierDto.taxId`, and `startDate` from\n`SupplierDto.dateEstablished` (converted to a `Date`).\n\n[NOTE]\n====\n`ownerSSN` appears on *both* `BusinessInformation` and as\n`PersonalInformation.taxId`, mapped from the same `SupplierDto.ownerSSN`.\n====\n\n== `Permission`\n\n[source,typescript]\n----\nexport class Permission extends BaseModel {\n    id: PermissionsEnum;\n    title: string;\n    level: number;        // 1 = root, 2 = child\n    parentId: number;\n    isSelected: boolean;\n    name: string;         // dotted, parent-prefixed\n    children: Permission[];\n\n    constructor(id: PermissionsEnum, children: Permission[] = []) {\n        super();\n        this.clear();\n        this.id = id;\n        this.name = PermissionsEnum[this.id];\n        this.children = children;\n    }\n}\n----\n\nThe constructor sets `name` to the bare enum name; `permissions.const.ts`\noverwrites it with the dotted, parent-prefixed form while building the tree. See\nxref:security/permissions.adoc[Permissions System].\n\n== `Notification` and `DashboardNotifications`\n\n`core/auth/_models/notifications.model.ts` — see\nxref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs] for\nthe full field list and the DTO key mapping.\n\nTyped data payloads:\n\n[source,typescript]\n----\nexport type IdentityDocumentExpiredDataDto = { airwallex_terms_accepted?: boolean };\n\nexport type SuspensionNotificationDataDto = {\n    reasonCode: SuspensionReason;\n    reasonCount?: number;\n    lastMpStatementEndDate?: string;\n    marketplace?: { marketplace_name: string; marketplace_key: string };\n};\n----\n\n== `UserIdentificationModel`\n\n`core/pyb/models/user-identification.model.ts` — the Segment identify traits.\nSee xref:analytics/event-logger.adoc[Event Logger].\n\n== Marketplace models\n\n[cols=\"2,3\"]\n|===\n| `MarketplaceModel` | A marketplace, keyed by `marketplaceKey`. Used for both the catalog and supplier connections.\n| `SupplierMarketplacesModel` | A supplier's connection — adds `apiStatus`, `draft`, `marketplaceStatus` (`hidden`, `rejected`), `marketplaceName` and `fundingParticulars.lastOptOutDate`.\n| `PayabilityComplianceModel` | One compliance requirement, keyed by `complianceKey`.\n| `CountryModel` | Country reference data, keyed by `countryCode`.\n|===\n\n`fundingParticulars.lastOptOutDate` drives the cancellation banners, with year\n`9999` as the \"no opt-out date\" sentinel — see\nxref:business/cancellation.adoc[Cancellation & Reactivation].\n\n== `UnifiedSupplierCard`\n\n`core/pyb/interfaces/unified-supplier-card.interface.ts` — the provider-agnostic\ncard model, with `mapMarqetaCardFromDto()`, `mapAirwallexCard()` and\n`synthesizeMarqetaPaymentMethod()`. See\nxref:business/seller-card.adoc[Seller Card].\n\n== Other models\n\n[cols=\"2,3\"]\n|===\n| `ProductSummaryModel` | Product offerings summary.\n| `PayabilityComplianceModel` | Compliance record.\n| `InstantAdvanceReport` | The Instant Advance report row — also used as `Status.activeInstantAdvance`.\n| `Role`, `SocialNetworks` | Legacy template models, unused in supplier flows.\n|===\n\n== CRUD helpers\n\n`core/_base/crud/`\n\n[cols=\"2,3\"]\n|===\n| `QueryParamsModel` | Paging, sorting and filter descriptor sent to paginated endpoints.\n| `QueryResultsModel` | `{ items, totalCount }` response wrapper.\n| `HttpUtilsService` | Query-string and HTTP parameter helpers.\n| `TypesUtilsService` | Date and type conversion helpers.\n| `BaseDataSource` | AG Grid server-side datasource adapter (`core/pyb/models/base/data-source/`).\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"reference/components.adoc","title":"Shared Components","description":"Complete inventory of shared widgets, modals, UI primitives, grid cells and shell components.","url":"https://docx.payability.com/supplier-client/reference/components.html","navOrder":56,"source":"= Shared Components\n:description: Complete inventory of shared widgets, modals, UI primitives, grid cells and shell components.\n\nShared UI lives under `src/app/views/partials/`, `src/app/views/theme/`,\n`src/app/views/templates/` and `src/app/core/pyb/components/`.\n\n== Widgets — `views/partials/widgets/`\n\nStore-connected cards, registered by `WidgetModule`.\n\n=== Account and identity\n\n[cols=\"2,2,3\"]\n|===\n| Widget | Permission | Shows\n\n| `your-account-widget` | — | Account status and activation progress.\n| `your-identity-widget` | — | KYC status and the ID-upload prompt.\n| `payments-config-widget` | `TRANSFER_INFORMATION` | The configured payout method.\n| `additional-documents-widget` | — | Outstanding document requirements.\n| `check-list` | — | Onboarding progress checklist.\n|===\n\n=== Financial products\n\n[cols=\"2,2,3\"]\n|===\n| Widget | Permission | Shows\n\n| `advance-line-widget` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_WIDGET` | Available credit, outstanding balance, draw CTA.\n| `advance-line-open-advances` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_REPORT` | Open drawdowns.\n| `advance-line-past-advances` | `ADVANCE_LINE.VIEW_ADVANCE_LINE_REPORT` | Historical drawdowns.\n| `advance-rate-widget` | — | The supplier's advance rate.\n| `instant-advance-widget` | `INSTANT_ADVANCE_REPORT_WIDGET` | Active Instant Advance and repayment progress.\n| `instant-access-widget` | — | Instant Transfer eligibility CTA.\n| `daily-summary` | `DAILY_SUMMARY` | Recent daily transaction totals.\n|===\n\n=== Cards and payments\n\n[cols=\"2,2,3\"]\n|===\n| `seller-cards` | `SELLER_CARD` | Card balance, last four, quick actions.\n| `seller-cards-panel` | `SELLER_CARD` | The unified \"Your Cards\" panel across both issuers.\n| `vendorpay-incoming-payments-widget` | `VENDOR_PAY` | Incoming vendor payments.\n|===\n\n=== Security\n\n[cols=\"2,3\"]\n|===\n| `password-widget` | Last password change and the change link.\n| `two-factor-widget` | 2FA status and the setup link.\n| `two-factor-setup-widget` | The enrolment wizard UI.\n|===\n\n=== Generic\n\n[cols=\"2,3\"]\n|===\n| `common-list-widget` | Reusable list widget.\n|===\n\n== Modals — `views/partials/modals/`\n\nRegistered by `ModalsModule`, opened with `MatDialog` or `PybDialogService`.\n\n=== Advance products\n\n[cols=\"2,3\"]\n|===\n| `advance-line/` | Draw input, confirmation and success dialogs.\n| `instant-advance-details-modal/` | Instant Advance terms.\n| `instant-transfer-details-modal/` | Instant Transfer fees and limits.\n| `instant-transfer-setup/` | `InstantTransferSetupModal` and `InstantTransferSetupSuccessModal` (TabaPay iframe).\n| `transfer-funds-modal/` | Transfer request.\n|===\n\n=== Long Term Contract\n\n[cols=\"2,3\"]\n|===\n| `ltc-options-modal/` | Option list.\n| `ltc-option-confirm-details-modal/` | Confirm the chosen option.\n| `ltc-sumarry-modal/` | Post-signature summary. (Folder name is misspelled in the repo.)\n| `generic-ltc-modal/` | Shared LTC dialog shell.\n|===\n\n=== Seller Card\n\n[cols=\"2,3\"]\n|===\n| `seller-card-activate-modal/` | Marqeta card activation.\n| `seller-card-order-modal/` | Order or replace a card.\n| `seller-card-block-modal/` / `seller-card-unblock-modal/` | Block and unblock confirmations.\n| `seller-card-view-modal/` | Reveal Marqeta card details.\n| `airwallex-card-view-modal/` | Reveal Airwallex card details.\n| `seller-card-transition-modal/` | Marqeta → Airwallex migration, optionally including the terms step.\n| `airwallex-pcard-terms-modal/` | Airwallex cardholder terms acceptance.\n|===\n\n=== Account and banking\n\n[cols=\"2,3\"]\n|===\n| `transfer-information/` | Payout method management.\n| `bank-verification-info-modal/` | Why bank verification is needed.\n| `lost-bank-connection-modal/` | Re-link a dropped Plaid connection.\n| `switch-cd-dialog/` | Switch between checking and deposit accounts.\n| `confirm-password-modal/` | Re-authenticate before a sensitive change.\n| `confirm-login-modal/` | Login confirmation.\n| `two-factor/` | 2FA setup and SMS challenge dialogs.\n| `entrust-verification-modal/` | Hosts the Onfido SDK for identity verification.\n|===\n\n=== Marketplaces\n\n[cols=\"2,3\"]\n|===\n| `add-selling-info-modal/` | Add marketplace credentials.\n| `amazon-suspended/` | The supplier's Amazon account is suspended.\n|===\n\n=== Notifications and messaging\n\n[cols=\"2,3\"]\n|===\n| `welcome-modal/` | First-session welcome (`new_sd_welcome`).\n| `referral-banner/` | Referral programme promotion.\n| `legal-info-modal/` | Legal disclosures.\n| `cancellation-survey/` | Multi-step cancellation survey. Registered globally in `AppModule`.\n| `vendor-pay-success-modal/` | Vendor Pay success. Currently superseded by `PybMessageModalComponent`.\n| `message-modal/` | `MessageModalService`, `PybMessageModalComponent`, `MessageModalData`, `MessageModalIconColor`, `MessageModalIconType`. The standard result dialog.\n| `simple-modal/` | Title, body, dismiss.\n| `simple-confirm-modal/` | Yes/no confirmation. Registered globally in `AppModule`.\n| `common-modals/` | Shared modal base configuration.\n|===\n\n=== Utility\n\n[cols=\"2,3\"]\n|===\n| `preview-image-modal/` | Full-size document preview.\n| `pwa-ios-install-modal/` | iOS add-to-home-screen instructions.\n|===\n\n== UI primitives — `views/partials/components/`\n\nRegistered by `ComponentsModule`.\n\n=== Status and feedback\n\n[cols=\"2,3\"]\n|===\n| `status-badge` | Coloured pill status label.\n| `status-chip` | Compact chip variant.\n| `banner` | Full-width dismissible banner (rendered by `BannerService`).\n| `update-app-banner` | PWA update-available prompt.\n| `two-factor-authentication-required-banner` | 2FA enrolment alert.\n| `re-sign-contract-banner` | Prompt to re-sign an updated agreement.\n| `skeleton` | Shimmer placeholders for loading states.\n|===\n\n=== Forms and input\n\n[cols=\"2,3\"]\n|===\n| `fields` | Dynamic form field rendering from a config array.\n| `image-upload` / `image-preview` | Document capture with preview.\n| `select-button` | Button-styled selection control.\n| `button` | Styled button with a loading state.\n| `go-back-button` | Breadcrumb-style back navigation.\n|===\n\n=== Display\n\n[cols=\"2,3\"]\n|===\n| `html-render` | Sanitized HTML rendering for CMS content.\n| `icon-list` | List with leading icons.\n| `hint-popover` | Info icon with tooltip, wired to help articles.\n| `ad-card` | Promotional card — the render target for CTA strategies (`IAdCard`).\n| `punch-card` | Reward/progress punch card.\n| `advance-line-description` | Formatted Advance Line terms block.\n| `card-visual` | Generic card artwork.\n| `pcard` | Marqeta card artwork.\n| `awx-card` | Airwallex card artwork.\n| `contact-support` | Support contact block.\n| `contacts` | Contact details from `contact.const.ts`.\n| `reports-mobile-content-row` | Mobile replacement for a grid row.\n|===\n\n== Content — `views/partials/content/`\n\n[cols=\"2,3\"]\n|===\n| `cta/` | `CtaFactory`, `CtaComponent`, the strategy set, session keys, and the standalone `open-invoices` CTA. See xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n| `export-dialog/` | `ExportDialogModal` — progress while a report is prepared.\n| `export-form-dialog/` | `ExportFormDialogComponent` — extended export options.\n| `tabapay-iframe/` | The TabaPay card-capture iframe and its event types.\n| `general/`, `widgets/` | Shared content blocks.\n|===\n\n== Grid — `core/pyb/components/grid/`\n\n`PybGridModule`, imported by `AppModule`.\n\n[cols=\"2,3\"]\n|===\n| `grid.component.ts` | The AG Grid wrapper and `GridOptions` handling.\n| `cells/cell/` | `CellComponent` — the generic cell.\n| `cells/base-cell.component.ts` | Shared cell behaviour.\n| `cells/currency/` | Currency formatting.\n| `cells/date/` | Locale-aware dates.\n| `cells/translate/` | Values routed through `@ngx-translate`.\n| `cells/progress-bar/` | Inline progress.\n| `cells/actions/` | Per-row action buttons.\n| `cells/toggle/` | Row-level switches.\n| `filter-popup/` | Column-header filter overlay.\n|===\n\nLower-level renderers, headers, overlays, status bars and value formatters live in\n`core/reports/ag-grid/`.\n\n== Other core components\n\n[cols=\"2,3\"]\n|===\n| `core/pyb/components/pagination/` | `PybPaginationModule` — pagination below grids.\n| `core/pyb/components/dynamic-container/` | Runtime component loading via `ViewContainerRef`.\n| `core/pyb/components/recaptcha/` | `RecaptchaComponent` — badge visibility control. xref:integrations/recaptcha.adoc[reCAPTCHA]\n|===\n\n== Shell — `views/theme/`\n\n[cols=\"2,3\"]\n|===\n| `base/` | `BaseComponent` — the authenticated shell hosting the router outlet.\n| `header/` | Top navigation from `MenuConfig`, plus `header-mobile/`.\n| `aside/` | Side navigation and `user-details/`.\n| `subheader/` | Page title and breadcrumbs, fed by `SubheaderService`.\n| `footer/` | Footer with build version.\n| `brand/` | Logo and white-label branding.\n|===\n\n`views/partials/layout/` holds `splash-screen/` and `subheader/`.\n\n== Templates — `views/templates/`\n\n[cols=\"2,3\"]\n|===\n| `simple-address-form/` | The reusable address form used by personal, business and shipping addresses.\n|===\n\n== Pipes and directives — `core/_base/layout/`\n\n[cols=\"2,3\"]\n|===\n| `safe` | Bypasses sanitization for trusted HTML.\n| `join` | Joins an array into a string.\n| `getObject` | Safe nested property access.\n| `firstLetter` | First character (initials).\n| `addPercent` | Appends a percent sign.\n| `maskedBankAccount` | Masks an account number.\n| `replaceSentence` | Token replacement in a sentence.\n| `ktContentAnimate` | Page transition animation on router navigation.\n| `ktSticky` | Sticky positioning with spacer and boundary support.\n| `pybDisabled` | Toggles the bound form control's disabled state.\n|===\n\n`core/pyb/pipes/filter-by.pipe.ts` provides `filterBy`.\n`PybCurrencyAccountingModule` comes from `@payability/pyb-ngx-components`.\n\nBehaviour, inputs and gotchas for each:\nxref:reference/utilities.adoc[Utility Reference].\n"},{"component":"supplier-client","module":"ROOT","path":"reference/utilities.adoc","title":"Utility Reference","description":"Shared helper functions, base classes, pipes, directives, constants and animations.","url":"https://docx.payability.com/supplier-client/reference/utilities.html","navOrder":57,"source":"= Utility Reference\n:description: Shared helper functions, base classes, pipes, directives, constants and animations.\n\nThe small shared building blocks live in two places, and the split is historical\nrather than principled:\n\n[cols=\"2,3\"]\n|===\n| `core/_base/` | Left over from the Metronic admin template the project was scaffolded from. Layout services, generic CRUD primitives, pipes and directives.\n| `core/pyb/utils/`, `core/pyb/constants/`, `core/pyb/animations/`, `core/pyb/interfaces/` | Payability-authored helpers.\n|===\n\nNothing here holds business rules. When a helper starts encoding product\nbehaviour it belongs in a service under `core/pyb/services/` instead — see\nxref:reference/services.adoc[Service Reference].\n\n== `Subscriber` — the subscription base class\n\n`core/pyb/utils/subscriber.class.ts`\n\n[source,typescript]\n----\n@Component({ selector: 'subscriber', template: '' })\nexport abstract class Subscriber implements OnDestroy {\n    protected subscriptions: Subscription[] = [];\n\n    ngOnDestroy() {\n        this.subscriptions.forEach(subscription => subscription.unsubscribe());\n    }\n\n    addSubscription(sub: Subscription): Subscriber { /* … */ }\n    addSubscriptions(...sub: Subscription[]): Subscriber { /* … */ }\n}\n----\n\nThis is the most-extended class in the codebase — around 109 classes inherit\nfrom it. Subclasses push their subscriptions in and get teardown for free.\n\n[IMPORTANT]\n====\n`Subscriber` is decorated with `@Component`, not `@Directive` or\n`@Injectable`, purely so Angular's compiler accepts the `ngOnDestroy`. Two\nESLint rules are disabled inline to allow the bare `subscriber` selector and\nthe missing class suffix. Do not copy this pattern for new base classes; use\n`@Directive()` without a selector, which is the supported Angular idiom.\n\nA subclass that declares its own `ngOnDestroy` **must** call `super.ngOnDestroy()`\nor every subscription leaks.\n====\n\nComponents that manage their own teardown instead use a `Subject` plus\n`takeUntil`, usually named `unsubscribe`. Both patterns are common; follow\nwhichever the surrounding file uses.\n\n== String, object and date helpers — `core/pyb/utils/utils.ts`\n\n[cols=\"2,3\"]\n|===\n| `dictToSelectOptions(dict)` | Turns a `{ key: label }` map into the `SelectOption[]` shape `pyb-dynamic-field` expects. Fed by the dictionaries in `core/pyb/dictionaries/` and by report filter maps.\n| `snakeToCamel`, `snakeToPascal`, `camelToSnake`, `camelToPascal`, `pascalToCamel` | Case conversion between API (`snake_case`) and client (`camelCase`) naming. `camelToSnake` is the one used most, for report filter parameters.\n| `formatDateProperties(obj, dateFormat)` | Walks an object and formats every `Date`-valued property with `PybDate`. Drops falsy values entirely. Report data sources call this on filter objects before sending them.\n| `formatDate(date)` | Single date to `MM/dd/yyyy`. Returns `null` for anything unparseable.\n| `decodeAuthToken(token)` | JWT payload as `AuthTokenDecoded`.\n| `sortByProperty(property, sortOrder, mapProperty?)` | Comparator factory for `Array.sort()`. `mapProperty` projects the value before comparison, e.g. to sort by a date's epoch.\n| `returnOnlyNumbersFromString(value)` | Strips non-digits — phone numbers, account numbers.\n| `addPrefix(prefix, value)` | Template-literal concatenation. Exists so `app-config.ts` can compose dial-able phone numbers declaratively.\n| `loadExternalScript(url, id)` | Appends a `<script>` to `<head>` and emits once it loads. Used by `user-details.component.ts` to inject the Zendesk widget (`ze-snippet`) on demand rather than on every page load.\n|===\n\n[WARNING]\n====\n`sortByProperty` returns `-1 * sortOrderValue` when *either* value is falsy, so\nrecords with an empty sort field are not merely ordered last — they compare\ninconsistently, which can make `Array.sort()` produce a different order for the\nsame input depending on the initial arrangement. Prefer server-side sorting for\nanything a user will notice.\n====\n\nTwo more single-purpose helpers sit alongside:\n\n[cols=\"2,3\"]\n|===\n| `jwt-decode.ts` — `jwtDecode(jwtToken)` | The same JWT payload decode as `decodeAuthToken`, untyped. Both exist; `jwtDecode` is the one used in `LoginComponent`.\n| `remove-empty.ts` — `removeEmpty(obj)` | Recursively drops `undefined` properties. Note it keeps `null` and empty strings, and recurses into every object-typed value including arrays and `Date` instances, which it will turn into plain objects.\n| `ads-checker.ts` — `checkIsAdBlockerEnabled()` | Returns `true` when `window.pybCRA` is `undefined`. The flag is set by `assets/js/pyb-ads.js`, a decoy script whose filename ad blockers match, so its absence means a blocker is active. Read once by `instant-transfer-setup.modal.ts` to show `adblockWarning` — Plaid's iframe does not load with a blocker running.\n| `errors.ts` — `NotImplementedError` | Thrown by unimplemented base-class methods such as `BaseDataSource.nextPage()`.\n|===\n\n== `PybDate`\n\n`core/pyb/utils/pyb-date.ts`\n\nA thin wrapper over `date-fns` with a Moment-like fluent API, introduced when\nMoment was removed. It supports only three units of time:\n\n[source,typescript]\n----\nexport type PybDateType   = string | Date | number;\nexport type PybUnitOfTime = 'days' | 'week' | 'year';\n----\n\n[cols=\"2,3\"]\n|===\n| `new PybDate(date?)` | Defaults to now. Any input is stringified then run through `parseISO`.\n| `PybDate.now()` | Epoch milliseconds.\n| `PybDate.nowUtc()` | Today at UTC midnight — used for date-picker minimums so the boundary does not shift by timezone.\n| `format(dateFormat)` | Delegates to `date-fns` `format`.\n| `diff(date, unitOfTime)` | Difference in days, weeks or years.\n| `add(amount, unit)`, `subtract(amount, unit)` | **Mutate** the instance and return `this`.\n| `isBefore(date)` | Instant comparison.\n| `get(unitOfTime)` | `'days'` returns the day *of the week*, `'week'` an ISO-ish week number, `'year'` the full year.\n| `toDate()`, `toString()` | Unwrap.\n|===\n\n[WARNING]\n====\nThree sharp edges:\n\n* `add` and `subtract` mutate in place. `const d = new PybDate(x); d.add(1, 'days');` changes `d`. Construct a fresh instance when you need the original.\n* The constructor accepts a `dateFormat` second argument and ignores it.\n* Passing a numeric epoch produces `parseISO('1699999999999')`, which is not a valid parse. Wrap epochs in `new Date()` first.\n* `get('days')` returns the weekday index, not the day of the month, despite the unit name.\n====\n\n== Generic CRUD primitives — `core/_base/crud/`\n\nExported from `core/_base/crud/index.ts`.\n\n[cols=\"2,3\"]\n|===\n| `BaseModel` | Template scaffolding: `_isEditMode`, `_userId`, `_createdDate`, `_updatedDate`. Inherited by `User`, `Role` and `Permission` and otherwise unused.\n| `BaseDataSource` | The *template's* data source. Superseded — see the warning below.\n| `QueryParamsModel<F>` | `filter`, `sortOrder`, `sortField`, `pageNumber`, `pageSize` (default 20). The report and AG Grid code all speak this shape. Re-exported by `@payability/pyb-ng2-api` too, and the API's copy is the one report data sources import.\n| `QueryResultsModel<T>` | `items`, `totalCount`, `errorMessage`.\n| `HttpExtenstionsModel` | In-memory `baseFilter`, `sortArray` and `searchInArray` over an array — a client-side stand-in for a paginated endpoint. The spelling of the class name is a typo that is now load-bearing.\n| `HttpUtilsService` | Wraps `HttpExtenstionsModel` and builds `HttpParams`. Provided in `AppModule` and injected nowhere.\n| `TypesUtilsService` | Number padding and `MM/dd/yyyy` date string conversion. Also provided in `AppModule` and injected nowhere.\n|===\n\n[WARNING]\n====\nThere are **two** classes named `BaseDataSource`:\n\n* `core/_base/crud/models/_base.datasource.ts` — the template original, used only by `core/auth/_data-sources/` and the legacy `core/reports/` components.\n* `core/pyb/models/base/data-source/base.datasource.ts` — the generic `BaseDataSource<T>` that every real report extends. Adds `loadError$`, `hasItems$`, `setEntityData()` and fetch-subscription cancellation.\n\nNew code wants the second one. Check the import path when reading report code.\nSee xref:features/reports.adoc[Reports] for the data source lifecycle.\n====\n\n`HttpUtilsService` and `TypesUtilsService` are provided in `AppModule` but have\nno injection sites left. They are safe to delete once someone confirms no\nexternal consumer imports them.\n\n== Pipes — `core/_base/layout/pipes/`\n\nEach pipe ships with its own `NgModule` that both declares and provides it, so a\nfeature module imports `JoinModule` rather than declaring `JoinPipe`.\n\n[cols=\"1,2,2\"]\n|===\n| Pipe | Module | Behaviour\n\n| `safe`\n| `SafeModule`\n| `bypassSecurityTrustHtml` / `Style` / `Script` / `Url` / `ResourceUrl` depending on the `type` argument, defaulting to HTML. Every use is a deliberate hole in Angular's sanitizer — only pass server-controlled or translated strings.\n\n| `join`\n| `JoinModule`\n| Space-joins an array; passes non-arrays through unchanged.\n\n| `getObject`\n| `GetObjectModule`\n| `objectPath.get(value, path)` for safe nested access.\n\n| `firstLetter`\n| `FirstLetterModule`\n| Initials: splits on spaces and takes the first character of each word, uppercased. Used for avatar placeholders.\n\n| `addPercent`\n| `AddPercentModule`\n| Appends `%`. Returns `''` for falsy input, so a genuine `0` renders as empty.\n\n| `maskedBankAccount`\n| `MaskedBankAccountModule`\n| Renders `&bull;&bull;&bull;&bull; 1234` from the last four characters. Emits HTML entities, so the result must be bound with `innerHTML`, not interpolated. Throws on `null`.\n\n| `replaceSentence`\n| `ReplaceSentenceModule`\n| `value.replace(sentence, newValue)` — first occurrence only, since the needle is a string rather than a global regex.\n|===\n\n`core/pyb/pipes/filter-by.pipe.ts` adds `filterBy`. Currency formatting comes\nfrom `PybCurrencyAccountingModule` in `@payability/pyb-ngx-components`.\n\n== Directives — `core/_base/layout/directives/`\n\n=== `ktContentAnimate`\n\nFades and slides content in on every `NavigationEnd`. It skips the animation\nwhen the URL contains the string `content-animation-blocked`. A route that\nshould not re-animate navigates with `contentAnimationBlockerParam`\n(`core/_base/layout/models/content-animate-blocker.model.ts`) in its query\nparams, which puts the key in the URL:\n\n[source,typescript]\n----\nexport const contentAnimationBlockerKey   = 'content-animation-blocked';\nexport const contentAnimationBlockerParam = { [contentAnimationBlockerKey]: true };\n----\n\nThe two-factor setup page uses this so stepping through its sub-states does not\nreplay the page transition.\n\n=== `pybDisabled`\n\nNot styling — it toggles the *form control's* disabled state, which keeps the\ncontrol out of `form.value` and out of validation:\n\n[source,typescript]\n----\n@Directive({ selector: '([formControlName], [formControl])[pybDisabled]' })\nexport class PybDisabledDirective implements OnInit {\n    @Input() set pybDisabled(disabled: boolean) {\n        this.disabled = disabled;\n        this.setDisabled();\n    }\n    // resolves the control from the host ControlContainer, then calls\n    // control.disable() / control.enable()\n}\n----\n\nThe selector requires a `formControlName` or `formControl` on the same element.\nWithout a resolvable control the directive logs a `console.warn` and does\nnothing. Non-boolean values are ignored, so `[pybDisabled]=\"someString\"` fails\nsilently.\n\nUse this instead of the `disabled` attribute on reactive-form inputs — Angular\nwarns about mixing `disabled` with `formControlName`, and this directive is the\nproject's answer to that.\n\n=== `ktSticky`\n\nAn extended fork of\nhttps://github.com/w11k/angular-sticky-things[angular-sticky-things]. Throttles\nscroll and resize to `animationFrameScheduler`, then sets `position: sticky`\nplus inline `top`, `width`, `z-index` and `background-color` on the host,\ntoggling the `is-sticky` and `boundary-reached` host classes.\n\n[cols=\"2,3\"]\n|===\n| `[scrollContainer]` | Selector or element to observe instead of `window`.\n| `[spacerElement]` | A sibling whose height absorbs the host when it goes sticky. Omitting it logs a dev-mode warning and the page will jump.\n| `[boundaryElement]` | Element the sticky region may not scroll past.\n| `[marginTop]`, `[marginBottom]` | Offsets, in pixels.\n| `[enable]` | Turns stickiness off, emitting once more so the element un-sticks.\n| `recalculate()` | Public; call after layout changes that move the element.\n|===\n\n[NOTE]\n====\n`makeSticky()` hard-codes `background-color: #fff` inline, which defeats\nxref:development/styling-and-theming.adoc[dark mode] on sticky elements. It also\nsets `width` in pixels, so a sticky element does not reflow until the next\nresize event.\n====\n\n== Animations — `core/pyb/animations/`\n\nOne trigger, parameterised by height:\n\n[source,typescript]\n----\nexport const collapseExpandAnimation = (height: number, duration = '500ms') =>\n    trigger('inOutAnimation', [\n        transition(':enter', [style({ height: 0 }), animate(`${duration} ease-out`,  style({ height }))]),\n        transition(':leave', [style({ height }),   animate(`${duration} ease-in`,  style({ height: 0 }))]),\n    ]);\n----\n\nBecause the trigger name is fixed at `inOutAnimation`, a component can register\nit only once. The caller supplies the expanded height, so the animation cannot\nadapt to content that measures itself.\n\n== Constants — `core/pyb/constants/`\n\n[cols=\"2,3\"]\n|===\n| `contact.const.ts` | `contacts.supportEmail` and `contacts.supportPhone`, both read from `APP_CONFIG`. Templates should use this rather than hard-coding support details.\n| `country-codes.ts` | `US_CODE`, re-exported from `constants/index.ts`. Drives the US-only branches in address and bank forms.\n| `help-articles.const.ts` | Zendesk article URLs, currently just `reconciliationReportExplanation`.\n| `ltc-flow.constants.ts` | `LtcContractActionType` — `New`, `Renew`, `Change`. See xref:business/long-term-contract.adoc[Long-Term Contract].\n| `suspension-reason.constants.ts` | Which suspension reasons are supported, which are Amazon-only, and `produceTranslationPath()`. See xref:business/suspension.adoc[Suspension].\n| `two-factor-banners.const.ts` | Banner copy keys for the 2FA nudge. See xref:security/two-factor.adoc[Two-Factor Authentication].\n| `re-sign-contract-banner.const.ts` | Banner copy keys for the re-sign prompt.\n|===\n\n=== `APP_CONFIG`\n\n`core/_config/app-config/app-config.ts` — the one place support contact details\nare declared. Phone numbers are stored once in display form and derived into\ndial-able form at module load:\n\n[source,typescript]\n----\n[PybDepartments.CUSTOMER_EXPERIENCE]: {\n    VIEW:        PhoneNumbers.CUSTOMER_EXPERIENCE,\n    ONLY_NUMBER: addPrefix(PhoneNumbersPrefixes.US, returnOnlyNumbersFromString(PhoneNumbers.CUSTOMER_EXPERIENCE)),\n},\n----\n\n`EMAILS` holds `CUSTOMER_EXPERIENCE`, `SALES`, `SUPPORT_API`, `PARTNERS`,\n`ACCOUNTS` and `SUSPENSION`. The suspension address is an individual's mailbox\nrather than a group alias, which is worth revisiting.\n\n== Shared interfaces\n\n`core/pyb/interfaces/` holds the cross-feature shapes that are not API DTOs —\n`Banner` and `BannerOptions`, `Action`, `Tooltip`, `AdCard`,\n`AuthTokenDecoded`, `DocusignHandlerParams`, `ApprovedProducts`,\n`InstantAdvanceTerms`, `UnifiedSupplierCard` and others. Field-level detail\nlives with the feature that owns each one; see\nxref:reference/models.adoc[Model Reference] and\nxref:business/domain-model.adoc[Domain Model].\n\nTwo type helpers are worth knowing:\n\n`core/pyb/utils/generic-form-group.interface.ts`::\n`GenericFormGroup<V>` narrows `FormGroup` so `value` is typed `V` and `controls`\nis `Record<keyof V, FormControl>`. Use it on form-heavy components to get\ncompile-time checking of control names. See\nxref:architecture/forms-and-fields.adoc[Forms and Fields].\n\n`core/_base/interfaces/`::\n`BreadcrumbTitle`, plus the `CountryCode`, `PcardStatus` and\n`PcardTypeIndicator` enums. Enum values are catalogued in\nxref:reference/enums.adoc[Enum Reference].\n"},{"component":"supplier-client","module":"ROOT","path":"reference/path-aliases.adoc","title":"Path Aliases","description":"The TypeScript path aliases, what they resolve to, and the one that resolves to two directories.","url":"https://docx.payability.com/supplier-client/reference/path-aliases.html","navOrder":58,"source":"= Path Aliases\n:description: The TypeScript path aliases, what they resolve to, and the one that resolves to two directories.\n\nDeclared in `tsconfig.json` with `baseUrl: \"src\"`.\n\n[source,json]\n----\n\"paths\": {\n    \"@pyb-core/*\":         [\"app/core/*\"],\n    \"@pyb-views/*\":        [\"app/views/*\"],\n    \"@pyb-environments/*\": [\"environments/*\"],\n    \"@pyb-assets/*\":       [\"assets/*\"],\n    \"@pyb-components/*\":   [\"app/views/partials/components/*\", \"app/core/pyb/components/*\"],\n    \"@pyb-modals/*\":       [\"app/views/partials/modals/*\"],\n    \"@pyb-vendors/*\":      [\"app/vendors/*\"],\n    \"@pyb-tests/*\":        [\"../tests/*\"]\n}\n----\n\n[cols=\"2,3,3\"]\n|===\n| Alias | Resolves to | Typical import\n\n| `@pyb-core/*`\n| `src/app/core/*`\n| `@pyb-core/pyb/services/permissions.service`\n\n| `@pyb-views/*`\n| `src/app/views/*`\n| `@pyb-views/pages/reports/reports.service`\n\n| `@pyb-environments/*`\n| `src/environments/*`\n| `@pyb-environments/environment.service`\n\n| `@pyb-assets/*`\n| `src/assets/*`\n| `@pyb-assets/i18n/en`\n\n| `@pyb-components/*`\n| `src/app/views/partials/components/*` **then** `src/app/core/pyb/components/*`\n| `@pyb-components/grid/grid.module`\n\n| `@pyb-modals/*`\n| `src/app/views/partials/modals/*`\n| `@pyb-modals/message-modal`\n\n| `@pyb-vendors/*`\n| `src/app/vendors/*`\n| `@pyb-vendors/cloud-watch/cloud-watch.service`\n\n| `@pyb-tests/*`\n| `tests/*` (repository root, outside `src`)\n| `@pyb-tests/helpers/store.mock`\n|===\n\n[WARNING]\n.`@pyb-components` maps to two directories\n====\nTypeScript tries `app/views/partials/components/*` first and falls back to\n`app/core/pyb/components/*`. Two consequences:\n\n* A folder name existing in *both* locations silently resolves to the partials\n  copy. Adding `grid/` under `views/partials/components/` would shadow the core\n  grid module used across every report.\n* Jumping to definition can land in the wrong tree. Check the resolved path when\n  something imports differently than expected.\n====\n\n== Usage rules\n\n[IMPORTANT]\n====\nNever use a relative import across module boundaries. Relative paths are only\nacceptable within the same folder or to a direct child.\n====\n\n[source,typescript]\n----\n// good\nimport { PermissionsService } from '@pyb-core/pyb/services/permissions.service';\nimport { WelcomeModalComponent } from '@pyb-modals/welcome-modal/welcome-modal.component';\nimport { CloudWatchService } from '@pyb-vendors/cloud-watch/cloud-watch.service';\n\n// good — sibling file in the same feature folder\nimport { reducer } from './balance.reducer';\n\n// bad\nimport { PermissionsService } from '../../../../core/pyb/services/permissions.service';\n----\n\n== Barrels\n\nPrefer the barrel where one exists:\n\n[cols=\"2,3\"]\n|===\n| `@pyb-core/auth` | `User`, `AuthService`, guards, selectors, `Permission`.\n| `@pyb-core/pyb/services` | `PersistentStateManager`, `UserService`, `TwoFactorSetupBannerInitializerService`, `SupportHelperService`.\n| `@pyb-core/pyb/features` | Feature slice exports.\n| `@pyb-core/pyb/entities` | Entity slice exports.\n| `@pyb-core/pyb/constants` | Shared constants.\n|===\n\n[NOTE]\n====\n`@pyb-core/pyb/services/index.ts` re-exports only four of the ~40 services in\nthat folder. Everything else must be imported by its full path — the barrel is\nnot a complete surface.\n====\n\n== Non-aliased packages\n\nTwo Payability packages are consumed as ordinary npm dependencies:\n\n[cols=\"2,3\"]\n|===\n| `@payability/pyb-ng2-api` | Generated API services, DTOs, models and shared enums (`SupplierStatusType`, `AdvanceLineStatusEnum`, `DocusignEventTypeEnum`, `CrossSellProductEnum`, …).\n| `@payability/pyb-ngx-components` | The shared UI kit (`PybDialogService`, `PybToastService`, `PybButtonType`, `PybColorPalette`, form and grid modules).\n|===\n\n== Adding an alias\n\n. Add the mapping to `compilerOptions.paths` in `tsconfig.json`.\n. Restart the TypeScript server — editors cache path mappings.\n. Check `tsconfig.app.json`, `tsconfig.spec.json` and `tsconfig.eslint.json`\n  extend the root config so the alias applies to builds, tests and lint alike.\n"},{"component":"supplier-client","module":"ROOT","path":"reference/glossary.adoc","title":"Glossary","description":"Domain terms, abbreviations, and the code identifiers behind them.","url":"https://docx.payability.com/supplier-client/reference/glossary.html","navOrder":59,"source":"= Glossary\n:description: Domain terms, abbreviations, and the code identifiers behind them.\n\n[cols=\"2,4\"]\n|===\n| Term | Meaning\n\n| *Advance Line*\n| A revolving credit facility a supplier draws against. `status.advanceLine`,\n  slices `advanceLine` / `advanceLineOffers`.\n  xref:business/advance-line.adoc[Advance Line]\n\n| *ACH agreement*\n| The direct-debit authorisation an expedited supplier signs before their first\n  draw. `DocusignFlowEnum.AdvanceLinePending`.\n\n| *APR contract*\n| A contract priced as an annual percentage rate rather than a flat fee.\n  `status.signAPRAdvanceContract`, `UserService.isAprUserContract()`.\n\n| *AVS*\n| Address Verification Service. Card networks match the billing address; failures\n  surface as TabaPay `CARD_AVS_DECLINED`.\n  xref:business/instant-transfer.adoc[Instant Transfer]\n\n| *Compliance*\n| One requirement a supplier must satisfy, identified by a UUID. Used for both\n  onboarding steps and per-marketplace connection requirements.\n  `PayabilityComplianceEnum`.\n\n| *CTA*\n| Call to action — a dismissible dashboard card generated from a notification by\n  `CtaFactory`. xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs]\n\n| *Expedited*\n| The `exp_onb` onboarding schema, which unlocks Advance Line access while the\n  supplier is still pending. `AmazonExpedited` (`amzn_exp_onb`) is a separate\n  value that does *not* satisfy the expedited predicate.\n\n| *Forward payment*\n| The internal name for Vendor Pay. `status.hasForwardPaymentEnabled`,\n  `IVendorPayStatus.forwardPaymentKey`. xref:business/vendor-pay.adoc[Vendor Pay]\n\n| *Free access*\n| A pricing cohort with no access fee. `status.isFreeAccess`,\n  `OnboardingTemplateCode.FreeAccess`. Selects an alternate translation namespace\n  on product cards.\n\n| *Instant Access*\n| The product surface around instant availability of funds.\n  `status.instantAccess`, `instantAccessActivated`.\n\n| *Instant Advance*\n| A one-off lump-sum advance against projected revenue. Mutually exclusive with\n  the modern Advance Line in the UI.\n  xref:business/instant-advance.adoc[Instant Advance]\n\n| *Instant Transfer*\n| Push-to-card payout for a fee. Gated by `ppmAvailable` and\n  `canRequestInstantTransfer`. xref:business/instant-transfer.adoc[Instant Transfer]\n\n| *Legacy Advance Line*\n| `AdvanceLineModeEnum.Legacy`. Suppliers on it receive no Advance Line\n  permissions; they see Instant Advance instead.\n\n| *LTC*\n| Long Term Contract — a commitment period in exchange for a discounted fee.\n  Note the permission is spelled `LCT_HISTORY`.\n  xref:business/long-term-contract.adoc[Long Term Contract]\n\n| *Marketplace key*\n| The fixed UUID identifying a sales channel. `MarketplaceEnum`.\n\n| *Opt-out date*\n| The last date a marketplace continues funding after a cancellation request.\n  `fundingParticulars.lastOptOutDate`; year `9999` means \"none\".\n\n| *p-card*\n| Prepaid card — the Marqeta Seller Card, modelled as a payment method.\n  `pcardAvailable`, `PCardStatus`, `selectPcardsPaymentMethods`.\n\n| *PPM*\n| Push-to-card payment method, i.e. Instant Transfer. `user.ppmAvailable`.\n\n| *Pre-activation status*\n| The onboarding progress bar data from\n  `OnboardingService.getPreActivationStatus()`. Driven by `TopBarService`.\n\n| *Schema*\n| The onboarding path assigned at registration. `OnboardingSchemaEnum`.\n\n| *Seller Card*\n| Payability's prepaid business card. Issued by Marqeta, migrating to Airwallex.\n  xref:business/seller-card.adoc[Seller Card]\n\n| *Supplier key*\n| The supplier's unique identifier — `user.id`, and the Segment user id.\n\n| *Underwriting (UW) status*\n| The credit decision on an Advance Line. `advanceLine.uwStatus`,\n  `AdvanceLineUWStatusEnum`.\n\n| *Unified card*\n| The provider-agnostic card model that lets Marqeta and Airwallex cards render\n  in one panel. `UnifiedSupplierCard`.\n\n| *Vendor Pay*\n| Sending funds from a supplier's balance to a vendor, and the incoming/outgoing\n  reports around it. xref:business/vendor-pay.adoc[Vendor Pay]\n|===\n\n== Third parties\n\n[cols=\"2,4\"]\n|===\n| *Airwallex* | Current Seller Card issuer. xref:integrations/card-issuers.adoc[Marqeta & Airwallex]\n| *Authy* | Backs SMS two-factor authentication. `authyId`, `authyRegVerified`.\n| *DocuSign* | Contract signing. xref:business/docusign.adoc[DocuSign Contract Flows]\n| *Entrust* | Identity verification sessions, rendered with the Onfido SDK. xref:integrations/entrust.adoc[Entrust Identity]\n| *Marqeta* | Original Seller Card issuer.\n| *Onfido* | The identity-capture SDK inside an Entrust session.\n| *Plaid* | Bank account linking. xref:integrations/plaid.adoc[Plaid]\n| *Segment* | Product analytics. xref:analytics/event-logger.adoc[Event Logger]\n| *Smartlook* | Session replay.\n| *TabaPay* | Debit-card capture for Instant Transfer.\n| *Zendesk* | Help centre.\n|===\n\n== Code idioms\n\n[cols=\"2,4\"]\n|===\n| *Barrel*\n| An `index.ts` re-exporting a folder's public surface.\n\n| *Feature key*\n| The string a slice registers under with `StoreModule.forFeature()`.\n  xref:state/overview.adoc[NgRX Overview]\n\n| *Flow* / *Task*\n| A multi-step journey built from `FlowTask` steps on a shared `FlowContext`.\n  xref:architecture/flows-and-tasks.adoc[Flows & Tasks Engine]\n\n| *Meta-reducer*\n| A reducer wrapper. Used here for `storeFreeze` and for `localStorage`\n  persistence. xref:state/persistence.adoc[State Persistence]\n\n| *Strategy* (CTA)\n| A class turning a notification into an `IAdCard` configuration.\n\n| *Subscriber*\n| The base class exposing `ngUnsubscribe$` for `takeUntil` cleanup.\n|===\n\n== Known spelling quirks\n\nThese are in the codebase and must be matched exactly.\n\n[cols=\"2,3\"]\n|===\n| `SingingComplete` | The DocuSign event member (value `signing_complete`).\n| `timezon` | The login endpoint's query parameter.\n| `LCT_HISTORY` | The Long Term Contract history permission.\n| `marquetaSandbox` | The Marqeta sandbox environment key.\n| `airwalexBaseUrl` | The Airwallex base URL environment key.\n| `buisnessDataCompleted` | A `Status` field.\n| `crateInstantTransferFeesData` | A `FeesScheduleHelperService` method.\n| `ltc-sumarry-modal` | The LTC summary modal folder.\n| `recaptcha.compontent.ts` | The reCAPTCHA component file.\n| `plaid.conststnts.ts` | The Plaid constants file.\n| `unsuported-marketplaces` | The unsupported-marketplaces folder.\n| `Loaded2FAInfor` | A 2FA action-type member.\n| `waitDesciption` | An `ExportService` parameter.\n|===\n"},{"component":"supplier-client","module":"ROOT","path":"development/setup.adoc","title":"Local Setup","description":"Getting the application running against a backend environment.","url":"https://docx.payability.com/supplier-client/development/setup.html","navOrder":60,"source":"= Local Setup\n:description: Getting the application running against a backend environment.\n\n== Prerequisites\n\n[cols=\"2,3\"]\n|===\n| Node | {node-version} — `nvm use {node-version}`\n| npm registry | GitHub Packages access for `@payability/*`\n| Chrome | Required by the Karma test runner\n|===\n\n=== npm registry authentication\n\n`@payability/pyb-ng2-api` and `@payability/pyb-ngx-components` are published to\nGitHub Packages. Create a personal access token with `repo`, `write:packages` and\n`read:packages`, then add to `~/.npmrc`:\n\n[source,ini]\n----\n//npm.pkg.github.com/:_authToken=YOUR_TOKEN\n@payability:registry=https://npm.pkg.github.com/\n----\n\n[IMPORTANT]\n====\nThis goes in your *home* `.npmrc`, not the repository's. `npm install` fails with\na 401 on the `@payability` scope without it.\n====\n\n== Install\n\n[source,console]\n----\n$ nvm use {node-version}\n$ npm ci\n----\n\n`npm ci` is preferred over `npm install` — it honours `package-lock.json` exactly.\nA `postinstall` hook installs the Husky git hooks.\n\n== Environment generation\n\n`src/environments/environment.ts` and `proxy.json` are *generated*. Nothing runs\nuntil you generate them.\n\n[source,console]\n----\n$ export ENV_NAME=dev-1\n$ npm run pre-build:proxy\n----\n\n`ENV_NAME` names the backend environment; `dev-[1..10]` and `pnega` (the default)\nare the usual values. `ENV_TYPE` selects the domain and config profile:\n\n[cols=\"1,3\"]\n|===\n| `dev` (default) | `<ENV_NAME>.dev.payability.com`, using the `dynamic` profile.\n| `prod` | The `prod` profile against `api.payability.com`.\n| `localhost` | The `relative` profile with relative API paths against `http://localhost:3000`.\n|===\n\n=== What `pre-build` does\n\n`config/pre-build.js` renders two templates with EJS:\n\n[cols=\"2,3\"]\n|===\n| `config/environment.template` | → `src/environments/environment.ts`\n| `config/proxy.template.json` | → `proxy.json` (only with `pre-build:proxy`)\n|===\n\nIt also:\n\n* stamps `version` from `package.json` and `buildTime` from `new Date()`,\n* computes `gci` as the first six digits of the SHA-1 of `ENV_NAME`,\n* sets `isProduction` when `ENV_NAME` is `prod` or `staging`,\n* derives `authTokenKey` as `auth-<env>-supplier4` (dev/localhost) or\n  `auth-supplier4-<timestamp hex>` (prod).\n\n[NOTE]\n.Proxy mode swaps the config profile\n====\n[source,javascript]\n----\nif (proxy) {\n    await prepareProxy(environmentName, config);\n    await prepareEnvironmentConfig(environmentName, configs.relative);   // <1>\n} else {\n    await prepareEnvironmentConfig(environmentName, config);\n}\n----\n<1> With `--HAS_PROXY`, the *relative* profile is written even though the proxy\n    targets the named environment. The app then makes same-origin requests that\n    the dev-server proxy forwards — which is what avoids CORS locally.\n====\n\ninclude::partial$env-generated-warning.adoc[]\n\n== Run\n\n[source,console]\n----\n$ npm start\n----\n\nServes on `http://localhost:4200` with `--proxy-config proxy.json`.\n\nOther run targets:\n\n[cols=\"2,3\"]\n|===\n| `npm run start:host` | Exposes the server on the network (`--host --disable-host-check`).\n| `npm run start:qa-test-1` | Uses the checked-in `proxy/proxy.qa-test-1.conf.json`.\n| `npm run start:test2` | Uses `proxy/proxy.test2.conf.json`.\n|===\n\n== Makefile shortcuts\n\n`Makefile` wraps the npm scripts and threads `ENV_NAME` / `ENV_TYPE` through.\n\n[source,console]\n----\n$ make help                    # list targets and the current ENV_NAME / ENV_TYPE\n$ make install\n$ make prebuild                # generate environment.ts + proxy.json\n$ make start                   # prebuild, then serve\n$ make start ENV_NAME=dev-1\n$ make dev-host                # serve on the network\n$ make dev-direct              # bake the absolute API URL in, no proxy\n$ make build-prod\n$ make lint / lint-fix / format\n$ make test / test-ci / e2e\n$ make i18n                    # extract translation keys\n$ make clean                   # rm -rf dist\n----\n\nDefaults are `ENV_NAME=pnega`, `ENV_TYPE=dev`.\n\n== Proxy routes\n\n`config/proxy.template.json` forwards:\n\n[cols=\"2,3\"]\n|===\n| `/bin` | `config.origin`\n| `/v1` | `config.origin`, rewriting `/v1` → ``\n| `/actionstools` | `config.frontendActionsToolsApiUrl`\n| `/marketplace-web-api` | `config.marketplaceUrl`\n| `/b2btaskmanager-sd` | `config.frontendTaskManager`\n| `/b2bdocument` | `config.b2bDocumentUrl`\n|===\n\nAll with `secure: false` and `changeOrigin: true`.\n\n== Memory settings\n\ninclude::partial$node-heap-warning.adoc[]\n\n== Local limitations\n\n[cols=\"2,3\"]\n|===\n| Amazon reconnect | `AmazonConnectorCookieService` writes cookies on `.payability.com`, so the return-detection path cannot be exercised on `localhost`.\n| Segment | `loadOnInitialization` is false outside production — `track()` calls do not reach Segment. Verify via the `EventLoggerService` console log.\n| Service worker | Registered only when `environment.production` is true.\n| `storeFreeze` | Active outside production, so mutations throw locally that would pass silently in prod. This is intended.\n| Dev-only permissions | `NEW_EGG_PROVIDER` is granted locally and logged to the console, but absent in production.\n| PayShield | The PayPal client id and plan ids are hardcoded, so a local checkout hits the real PayPal plans.\n|===\n\n== Working against local libraries\n\nTo develop `@payability/pyb-ng2-api` or `@payability/pyb-ngx-components`\nalongside the app, use `npm link` from the library directory and then link it\ninto this repository.\n"},{"component":"supplier-client","module":"ROOT","path":"development/build-and-release.adoc","title":"Build & Release","description":"Build targets, the CodeBuild pipeline, the service worker, and how versions are stamped.","url":"https://docx.payability.com/supplier-client/development/build-and-release.html","navOrder":61,"source":"= Build & Release\n:description: Build targets, the CodeBuild pipeline, the service worker, and how versions are stamped.\n\n== Angular workspace\n\n`angular.json` defines two projects:\n\n[cols=\"2,3\"]\n|===\n| `metronic` | The application. Targets: `build`, `serve`, `extract-i18n`, `test`, `lint`.\n| `metronic-e2e` | Protractor end-to-end scaffolding. Targets: `e2e`, `lint`.\n|===\n\n[NOTE]\n====\n`metronic` is the name of the admin template the project started from. It is the\nAngular project name only; it has no runtime meaning.\n====\n\nBuild configurations: `production`, `development`, `swtest`, `test2`.\n\n== npm scripts\n\n[cols=\"2,3\"]\n|===\n| `npm run pre-build` | Generate `environment.ts` only.\n| `npm run pre-build:proxy` | Generate `environment.ts` *and* `proxy.json`.\n| `npm start` | Dev server with `--proxy-config proxy.json`.\n| `npm run build` | Development build.\n| `npm run build:prod` | `pre-build` then a production build.\n| `npm run build:ci` | Production build without `pre-build` (CI generates it separately).\n| `npm run build:swtest` | Build with the `swtest` configuration (service-worker testing).\n| `npm run build:stats` | Build with `--stats-json`.\n| `npm run analyze` | Production build plus `webpack-bundle-analyzer`.\n| `npm run bundle-report` | Analyzer against an existing `dist/stats.json`.\n| `npm test` | Karma in watch mode.\n| `npm run test:ci` | `lint:quiet` then headless Karma with coverage.\n| `npm run test:pre-push` | Headless Karma, no coverage (the pre-push hook).\n| `npm run lint` / `lint:fix` / `lint:cache` | ESLint variants.\n| `npm run format-all` | Prettier over `src/app/**/*.{ts,js}`.\n| `npm run extract-translations` | `ngx-translate-extract` over `./src`.\n| `npm run cypress:open` | Cypress runner.\n|===\n\ninclude::partial$node-heap-warning.adoc[]\n\n== CodeBuild pipeline\n\n`buildspec.yaml`:\n\n[source,yaml]\n----\nenv:\n    variables:\n        PYB_IAC_AWS_DEFAULT_REGION: 'us-east-1'\n    secrets-manager:\n        NPM_TOKEN: aws-codebuild-token-eva\n\nphases:\n    install:\n        runtime-versions: { nodejs: 12, python: 3.8 }\n        commands:\n            - export CHROME_BIN=/usr/bin/chromium-browser\n            - echo \"//npm.pkg.github.com/:_authToken=\\${NPM_TOKEN}\" >> ~/.npmrc\n            - echo \"@payability:registry=https://npm.pkg.github.com/\" >> ~/.npmrc\n            - npm install\n            - npm install -g @angular/cli\n    pre_build:\n        commands:\n            - npm run pre-build\n            # - npm run test:ci                                   # <1>\n    build:\n        commands:\n            - |\n                if [ $ENV_NAME = \"prod\" ] || [ $ENV_NAME = \"staging\" ] ; then\n                  npm run build:prod\n                else\n                  npm run build\n                fi\nartifacts:\n    files: ./**/*\n    name: pyb-ng2-sd-build\n    base-directory: dist\n----\n<1> The test step is *commented out*. CI does not currently run the unit suite —\n    the only automated gate is the Husky pre-push hook on a developer's machine.\n\n[cols=\"2,3\"]\n|===\n| `ENV_NAME` | Selects the backend environment. `prod` and `staging` get a production build.\n| `ENV_TYPE` | Selects the domain and config profile.\n| `NPM_TOKEN` | Pulled from Secrets Manager to authenticate against GitHub Packages.\n| `CHROME_BIN` | Set for Karma, even though the test step is disabled.\n| Artifact | The contents of `dist/`, named `pyb-ng2-sd-build`.\n|===\n\n[NOTE]\n====\n`build:prod` runs `pre-build` again, so the environment file is generated twice\nfor production builds. Harmless, but it means `buildTime` reflects the second\nrun.\n====\n\n== Version stamping\n\n`config/pre-build.js` writes three provenance fields into `environment.ts`:\n\n[cols=\"2,3\"]\n|===\n| `version` | From `package.json`.\n| `buildTime` | `new Date().toString()` at generation time.\n| `env` | The `ENV_NAME` value.\n|===\n\nThey are surfaced in the UI footer, so a screenshot identifies the exact build.\n\nBumping the app version means editing `package.json` and adding a `CHANGELOG.md`\nentry.\n\n== Service worker\n\nRegistered only in production:\n\n[source,typescript]\n----\nServiceWorkerModule.register('ngsw-worker.js', {\n    enabled: environment.production,\n    registrationStrategy: 'registerImmediately',\n})\n----\n\n`ngsw-config.json` asset groups:\n\n[cols=\"2,2,3\"]\n|===\n| Group | Install mode | Resources\n\n| `app` | `prefetch` | `index.html`, `manifest.webmanifest`, `*.css`, `*.js`, `pyb-glyph-dark.svg`\n| `assets` | `lazy` | `/assets/**` and image/font extensions\n| `blog-media` | `lazy`, `updateMode: lazy` | `https://www.payability.com/wp-content/uploads/**/*.*`\n|===\n\nPlus data groups for themes.\n\n`ServiceWorkerService` watches for a new version and prompts a reload through the\n`update-app-banner` component.\n\nUse `npm run build:swtest` to build with service-worker behaviour enabled outside\nproduction.\n\n== PWA\n\n`src/manifest.webmanifest` declares the installable app.\n`modals/pwa-ios-install-modal/` gives iOS users add-to-home-screen instructions,\nsince iOS has no install prompt.\n\n== Bundle analysis\n\n[source,console]\n----\n$ npm run analyze                # build + open the analyzer\n$ npm run build:stats            # just produce dist/stats.json\n$ npm run bundle-report          # analyze an existing stats.json\n----\n\n`source-map-explorer` is also available as a dependency.\n\nEvery feature module is lazy-loaded, so a large initial bundle usually means\nsomething eagerly imported a feature module — check `AppModule` and\n`PartialsModule` first.\n\n== Git hooks\n\nInstalled by the `postinstall` script (`husky install`).\n\n[cols=\"2,3\"]\n|===\n| pre-commit | `pretty-quick --staged`, then `lint-staged` per `.lintstagedrc.json`.\n| pre-push | `npm run test:pre-push` — headless Karma without coverage.\n|===\n\n[WARNING]\n====\nSince CI does not run tests, the pre-push hook is the only automated test gate.\nBypassing it with `--no-verify` means untested code reaches the pipeline.\n====\n\n== Browser support\n\n`browserslist` at the repository root and `src/browserslist` drive transpilation\ntargets and autoprefixing. `polyfills.ts` covers the remainder.\n"},{"component":"supplier-client","module":"ROOT","path":"development/testing.adoc","title":"Testing","description":"Unit tests with Karma and Jasmine, shared mocks, and the Cypress end-to-end suite.","url":"https://docx.payability.com/supplier-client/development/testing.html","navOrder":62,"source":"= Testing\n:description: Unit tests with Karma and Jasmine, shared mocks, and the Cypress end-to-end suite.\n\n== Unit tests\n\nKarma + Jasmine, with specs colocated beside the code they cover\n(`foo.service.ts` / `foo.service.spec.ts`). There are around 246 spec files in\n`src/`.\n\n[source,console]\n----\n$ npm test              # watch mode\n$ npm run test:ci       # lint:quiet + headless Chrome + coverage\n$ npm run test:pre-push # headless Chrome, no coverage (the pre-push hook)\n$ npm run test:coverage # watch mode with coverage\n----\n\n=== Karma configuration\n\n`src/karma.conf.js`\n\n[cols=\"2,3\"]\n|===\n| Frameworks | `jasmine`, `@angular-devkit/build-angular`\n| Chrome binary | `process.env.CHROME_BIN = require('puppeteer').executablePath()` — Puppeteer supplies Chrome, so no system install is needed.\n| Browser | `ChromeHeadlessNoSandbox` — headless with `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-gpu`, for containerised CI.\n| Random order | *Disabled* (`client.jasmine.random: false`).\n| Coverage output | `coverage/pyb-ng2-supplier-client/` as `html`, `lcovonly` and `text-summary`.\n| Fail fast | `specReporters.failFast: true`.\n|===\n\n[WARNING]\n.Spec order is deterministic\n====\n`random: false` means specs always run in the same order, so a suite with\ncross-spec state leakage can pass consistently and then fail when files are\nadded or renamed. Reset shared state in `beforeEach` rather than relying on\norder.\n====\n\n`src/test.ts` bootstraps the environment and loads every `*.spec.ts` through\n`require.context`.\n\n== Shared mocks\n\n`tests/` sits outside `src/` and is reachable through the `@pyb-tests/*` alias.\n\n[cols=\"2,3\"]\n|===\n| `tests/mocks/store/user-initial-state.mock.ts` | A baseline auth state.\n| `tests/mocks/store/instant-advance.state.mock.ts` | Instant Advance slice state.\n| `tests/mocks/store/instant-advance-repayment.state.mock.ts` | Repayment slice state.\n| `tests/mocks/notifications/advance-line.mock.ts` | Advance Line notification payload.\n| `tests/mocks/notifications/long-term-contract.mock.ts` | LTC notification payload.\n| `tests/mocks/notifications/airwallex-pcard-terms.mock.ts` | Airwallex terms notification payload.\n| `tests/mocks/permissions/` | `permission.service.ts`, `permission.directive.ts`, `permissions.module.ts` — stand-ins for `ngx-permissions`.\n| `tests/mocks/pipe/` | `translate.pipe.mocks.ts`, `titlecase.pipe.mocks.ts`.\n| `tests/mocks/material/mat-icon.mocks.ts` | `MatIcon` stub.\n| `tests/mocks/reports/instant-advance.mock.ts` | Report row fixtures.\n| `tests/mocks/change-password.service.mock.ts` | Password-change service stub.\n| `tests/mocks/utils.ts` | Shared helpers.\n| `tests/utils/dispatch-event.ts` | DOM event dispatch helper.\n|===\n\nIn-repo mocks follow the `ResourceNameMockClassType` convention, e.g.\n`PermissionsServiceMock` in\n`core/pyb/services/permissions.service.mock.ts`.\n\n== Testing libraries\n\n[cols=\"2,3\"]\n|===\n| `ng-mocks` | Component, directive, pipe and module mocking.\n| `ng-bullet` | Faster `TestBed` reconfiguration between specs.\n| `jasmine-core`, `karma-jasmine` | Framework and runner integration.\n| `karma-coverage` | Coverage reporting.\n| `puppeteer` | Supplies the headless Chrome binary.\n|===\n\n== What to test\n\n[cols=\"2,3\"]\n|===\n| Services with branching logic\n| `PermissionsService`, `DocusignVerificationService`,\n  `SuspensionReasonInitializerService`, card operation services. These encode the\n  business rules and have the highest defect cost.\n\n| Reducers\n| Pure functions with no setup — cheap and high value.\n\n| Selectors\n| Especially composed ones such as `selectIsSupplierCancelled`.\n\n| Effects\n| Use `provideMockActions` and marble testing for the async shape.\n\n| Guards\n| `SellerCardListGuard` and `DocuSignGuard` in particular — their routing\n  decisions are hard to reproduce manually.\n\n| Flows\n| `AdvanceLineWithdrawFlow` branches on user state while building; both branches\n  deserve a spec.\n|===\n\n== End-to-end tests\n\nCypress, in `cypress/`.\n\n[source,console]\n----\n$ npm run cypress:open\n----\n\n`cypress.json`:\n\n[source,json]\n----\n{\n    \"viewportWidth\": 1200,\n    \"viewportHeight\": 800,\n    \"env\": { \"environment\": \"stagging\", \"localUrl\": \"http://localhost:4200/\" }\n}\n----\n\n=== Specs\n\n[cols=\"2,3\"]\n|===\n| `integration/auth/auth.spec.ts` | Authentication.\n| `integration/signing-in/signing-in.spec.ts` | Sign-in journey.\n| `integration/password-recovery/password-recovery.spec.ts` | Password reset.\n| `integration/2FA/2FA.spec.ts` | Two-factor authentication.\n| `integration/permissions/permissions.spec.ts` | Permission-driven visibility.\n| `integration/personal-data-access/personal-data-access.spec.ts` | Personal data access.\n|===\n\n=== Support and utilities\n\n[cols=\"2,3\"]\n|===\n| `support/auth/login.command.ts` | `cy.login()`.\n| `support/auth/autologin.command.ts` | Token-based login that skips the form.\n| `support/auth/logout.command.ts` | `cy.logout()`.\n| `support/commands.ts`, `support/index.ts` | Command registration.\n| `utils/auth/auth.utils.ts` | Auth helpers.\n| `utils/bootstrap/bootstrap.utils.ts` | App bootstrap helpers.\n| `utils/http/http.utils.ts` | Request interception helpers.\n| `fixtures/user.json` | The test user.\n|===\n\nThe suite concentrates on authentication and permissions — the two areas where a\nregression is both most likely and most damaging.\n\n[NOTE]\n====\n`cypress/` has its own `tsconfig.json` and `tsconfig.eslint.json`, because\nCypress types conflict with Jasmine types in the same project.\n====\n\n== Protractor\n\n`e2e/` and the `metronic-e2e` project in `angular.json` are legacy Protractor\nscaffolding, superseded by Cypress.\n\n== CI\n\n[WARNING]\n====\nThe test step in `buildspec.yaml` is commented out:\n\n[source,yaml]\n----\n    pre_build:\n        commands:\n            - npm run pre-build\n            # - npm run test:ci\n----\n\nThe pipeline does not run tests. The Husky pre-push hook\n(`npm run test:pre-push`) is the only automated gate, so `git push --no-verify`\nlets untested code through.\n====\n\n== Linting\n\n[source,console]\n----\n$ npm run lint          # ESLint over ./src\n$ npm run lint:quiet    # errors only (used by test:ci)\n$ npm run lint:fix\n$ npm run lint:cache    # content-hash cache for faster reruns\n----\n\nConfiguration lives in `.eslintrc.js`, with `@angular-eslint`,\n`@typescript-eslint`, `eslint-plugin-import`, `eslint-plugin-prefer-arrow`,\n`eslint-plugin-unused-imports` and `eslint-plugin-prettier`.\n"},{"component":"supplier-client","module":"ROOT","path":"development/i18n.adoc","title":"Internationalization","description":"How translations are loaded, where keys live, and the rules for adding copy.","url":"https://docx.payability.com/supplier-client/development/i18n.html","navOrder":63,"source":"= Internationalization\n:description: How translations are loaded, where keys live, and the rules for adding copy.\n\nEvery user-facing string goes through `@ngx-translate`. English is currently the\nonly language, but the mechanism is fully in place.\n\n== Loader\n\nThe app uses a *local* loader rather than fetching JSON at runtime:\n\n[source,typescript]\n----\n// src/app/translate-loader.ts\nconst languages = {\n    en: {\n        load: () => fromPromise(import('../assets/i18n/en')).pipe(map(res => res.default)),\n    },\n};\n\nexport class LocalTranslateLoader implements TranslateLoader {\n    getTranslation(lang: string): Observable<any> {\n        return languages[lang].load();\n    }\n}\n----\n\nTranslations are a *TypeScript module*, dynamically imported and bundled by\nwebpack — not a static JSON asset.\n\n[NOTE]\n====\n`HttpLoaderFactory` (a `TranslateHttpLoader` against `./assets/i18n/*.json`) is\nalso exported from the same file but is not the loader `AppModule` uses. Adding a\nlanguage means adding an entry to the `languages` map, not dropping a JSON file\ninto `assets/`.\n====\n\n== The translation file\n\n`src/assets/i18n/en.ts` — around 3,800 lines, exporting a nested object as the\ndefault export.\n\nBecause it is TypeScript, it can *import*:\n\n[source,typescript]\n----\nimport { CrossSellProductEnum } from '@payability/pyb-ng2-api';\nimport { MIN_INSTANT_TRANSFER_AMOUNT } from '.../transfer-funds.const';\nimport { APP_CONFIG } from '../../app/core/_config/app-config/app-config';\nimport { descriptionLinkClassName } from '.../transfer-error-modal.const';\n\nconst i18n = {\n    AUTH: {\n        CHANGE: {\n            CURRENT: 'Current password',\n            FORGOT: 'Forgot password?',\n            // …\n        },\n    },\n};\n----\n\nEnum members become computed keys, and constants can be interpolated directly, so\na value such as a minimum transfer amount is defined once and reused in copy.\n\n[WARNING]\n====\nThe translation file imports from `src/app`. A circular import — an app file\nimporting `en.ts` — will break the build. Keep the dependency direction one-way.\n====\n\n== Initialization\n\nLoading is an `APP_INITIALIZER` that *blocks* bootstrap:\n\n[source,typescript]\n----\nconst translateFactory = (translate: TranslateService) => {\n    return async () => {\n        translate.setDefaultLang(DEFAULT_LANGUAGE);\n        translate.use(DEFAULT_LANGUAGE);\n        return new Promise(resolve => translate.onLangChange.subscribe(change => resolve(change)));\n    };\n};\n----\n\nNothing renders until translations are ready, which is why raw keys are never\nbriefly visible.\n\n`DEFAULT_LANGUAGE` is `'en'`, defined in\n`core/_base/layout/services/translation.service.ts`. `TranslationService`\nregisters the available languages and sets the fallback.\n\n== Using translations\n\nIn templates:\n\n[source,html]\n----\n<span>{{ 'AUTH.CHANGE.HEADER' | translate }}</span>\n<span>{{ 'BANNERS.CANCELLATION.PENDING.LAST_DAY' | translate: { marketplaceName, optOutDate } }}</span>\n----\n\nIn code:\n\n[source,typescript]\n----\nthis.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.TITLE');\nthis.translateService.instant('FEATURES.ADVANCE_LINE.MODALS.SUCCESS.HEADER', { amount });\nthis.translateService.get('CTA.AIRWALLEX_PCARD_TERMS.SUCCESS').subscribe(({ HEADER, DESCRIPTION }) => { /* … */ });\n----\n\n[NOTE]\n====\n`get()` on a *branch* key returns the whole sub-object. The notification success\nmodal relies on this, destructuring `{ HEADER, DESCRIPTION }` from one lookup.\n====\n\n== Key naming\n\n[cols=\"2,3\"]\n|===\n| `AUTH.*` | Authentication screens.\n| `SETTINGS.MANAGE_ACCOUNT.*` | Account settings, mirroring the tab structure.\n| `MODALS.*` | Dialog copy — `MODALS.AIRWALLEX_PCARD_TERMS`, `MODALS.AP_AMAZON.SUCCESS`, `MODALS.LEGAL_INFO_CONFIRM.AGREEMENT_SIGNED`.\n| `BANNERS.*` | Banner copy — `BANNERS.CANCELLATION.PENDING.LAST_DAY`.\n| `CTA.*` | Notification CTA copy — `CTA.PRE_ADVANCE_LINE.SUCCESS`.\n| `FEATURES.*` | Feature-specific copy — `FEATURES.ADVANCE_LINE.MODALS.SUCCESS.*`.\n| `SUSPENSION_REASON.*` | Suspension copy, keyed by the stripped reason code.\n| `PRODUCT_OFFERINGS.PRODUCTS.*` | Product card copy, keyed by `CrossSellProductEnum`.\n| `SELLER_CARD.*`, `CARDS_PANEL.*` | Card status and provider labels.\n| `TOP_BAR_STATUS.STEPS.*` | Onboarding step titles.\n| `MENU.*` | Navigation labels.\n| `COMMON.*` | Shared words — `COMMON.CLOSE`, `COMMON.NEXT`, `COMMON.TRY_AGAIN`, `COMMON.ERRORS.SOMETHING_WENT_WRONG`.\n| `INSTANT_TRANSFER.*` | Instant Transfer copy, including the failure modals.\n| `PAYSHIELD_SUBSCRIPTION.*` | PayShield plan names and descriptions.\n|===\n\n== Computed keys\n\nSeveral places build keys at runtime. Grep will not find them, so they need care.\n\n[cols=\"2,3\"]\n|===\n| Suspension | `${copyNamespace}.${reasonCode}` and `+${copyNamespace}.${reasonCode}.${marketplace}+`, with `.first` / `.next` suffixes for failed payments.\n| Product offerings | `freeAccessProduct(type)` / `commonProduct(type)` prefixes, four keys each.\n| Onboarding steps | `TOP_BAR_STATUS.STEPS.${stepKey}`.\n| Card status | `SELLER_CARD.ACTIVE` / `.BLOCKED` / `.PENDING_ACTIVATION` / `.TERMINATED` via `unifiedStatusLabelMap`.\n| CTA success | `phraseKeyByType[type]` with a fallback to `CTA.PRE_INSTANT_ADVANCE.SUCCESS`.\n|===\n\n[IMPORTANT]\n====\nA missing computed key renders as the raw key string, and only for the cohort\nthat hits that branch. Adding a suspension reason, a product, or an\naccept-able notification means adding its key *and* exercising that branch.\n====\n\n== Extraction\n\n[source,console]\n----\n$ npm run extract-translations\n----\n\nruns:\n\n[source,console]\n----\nngx-translate-extract --input ./src --output ./src/assets/i18n/*.json --sort --format namespaced-json\n----\n\n[WARNING]\n====\nThe extractor writes `*.json`, but the loader reads `en.ts`. Extraction is\ntherefore an *audit* tool for finding keys used in templates, not a generator for\nthe file the app actually loads. Add keys to `en.ts` by hand.\n====\n\n`make i18n` is the Makefile equivalent.\n\n== HTML in copy\n\nKeys ending `_HTML` (for example\n`INSTANT_TRANSFER.FAILURE_ADDRESS_MODAL.DESCRIPTION_HTML`) contain markup and are\nrendered through the `html-render` component or the `safe` pipe. `HyperlinkService`\ncan also inject links into plain text at runtime, using the class from\n`HYPER_LINK_CONFIG_TOKEN`.\n\n== Known exceptions\n\n[WARNING]\n====\nSome strings are hardcoded English rather than translated:\n\n* `SendMoneyPageService` — the success toast and confirmation modal text.\n* `ExportService` — the default `'Export'` and `'Your report is preparing'`\n  parameter values.\n* `HttpErrorHandlerInterceptor` — the account-locked message.\n\nThese should move into `en.ts` when touched.\n====\n\n== Adding a language\n\n. Add `src/assets/i18n/<lang>.ts` with the same key structure.\n. Add an entry to the `languages` map in `src/app/translate-loader.ts`.\n. Register the language with `TranslationService`.\n. Add a language selector and persist the choice.\n. Review date and currency formatting — `DateFormatsEnum` is US-centric\n  (`MM/dd/yyyy`) and `CurrencyPipe` is used without an explicit locale.\n"},{"component":"supplier-client","module":"ROOT","path":"development/styling-and-theming.adoc","title":"Styling and Theming","description":"The SCSS layer cake, runtime theme switching, dark mode, white-label providers, and the icon registry.","url":"https://docx.payability.com/supplier-client/development/styling-and-theming.html","navOrder":64,"source":"= Styling and Theming\n:description: The SCSS layer cake, runtime theme switching, dark mode, white-label providers, and the icon registry.\n\nStyling in this application comes from four sources stacked on top of each\nother, and understanding the order is the difference between a one-line fix and\nan afternoon of specificity wars:\n\n. **Angular Material** — the component primitives.\n. **Bootstrap 4** — the grid and utility classes.\n. **Metronic** — the admin template the project was scaffolded from, which owns the shell layout (`kt-` prefixed classes).\n. **Payability** — `@payability/pyb-ngx-components` plus this repo's own `src/assets/sass/`.\n\nOn top of the compiled bundle sits a *runtime* theme layer: two or three CSS\nfiles fetched over HTTP and injected as `<style>` elements after boot. That\nlayer is what makes dark mode and white-labelling possible without a rebuild.\n\n== Global stylesheets\n\n`angular.json` loads five global entries, in this order:\n\n[source,json]\n----\n\"styles\": [\n    \"src/styles.scss\",\n    \"node_modules/bootstrap/dist/css/bootstrap.css\",\n    \"node_modules/intl-tel-input/build/css/intlTelInput.css\",\n    \"node_modules/perfect-scrollbar/css/perfect-scrollbar.css\",\n    \"src/assets/sass/style.angular.scss\"\n]\n----\n\n`stylePreprocessorOptions.includePaths` adds `src/assets` and two directories\ninside `@payability/pyb-ngx-components`, which is why SCSS files can\n`@import 'sass/global/…'` and `@import 'themes/…'` without relative paths.\n\n[NOTE]\n====\nBootstrap arrives twice — once as the prebuilt CSS in the `styles` array and\nagain as SCSS via `@import '~bootstrap/scss/bootstrap'` inside both\n`styles.scss` and `style.angular.scss`. The duplication is longstanding and\ninflates the CSS bundle. Removing it requires checking that nothing depends on\nthe later-wins cascade.\n====\n\n=== `src/styles.scss`\n\nThe catch-all. Material's `indigo-pink` prebuilt theme, the Material Icons\nfont, AG Grid styles, the two aside skins (`.pyb-aside-dark`,\n`.pyb-aside-light`), and a run of global overrides — hiding the Bootstrap\ndropdown caret, hiding the reCAPTCHA badge, `body { overflow: hidden }`, and\ngenerated utility classes:\n\n[source,scss]\n----\n@for $i from 0 through 8 {\n    .mat-elevation-#{$i} { @include mat.elevation($i); }\n}\n\n@for $i from 0 through 5 {\n    .border-radius-#{$i * 5} { border-radius: #{$i * 5}px; }\n}\n----\n\n`body { overflow: hidden }` is load-bearing: the shell scrolls an inner\ncontainer, not the document. Removing it produces a double scrollbar.\n\n=== `src/assets/sass/style.angular.scss`\n\nThe layout cake, with an explicit warning in the file:\n\n[source,scss]\n----\n////////////////////////////////////////////////////////////////\n///////////// DO NOT MODIFY BELOW INCLUDE ORDER ////////////////\n////////////////////////////////////////////////////////////////\n----\n\nThe order is: shared config from `pyb-ngx-components` and local `_config.scss`,\nthen Bootstrap variables and framework, then Material includes, then component\nincludes, then per-region layout config and layout (`header`, `brand`, `aside`,\n`content`, `footer`), and finally `custom/index`, `_fonts.scss` and the two\nruntime themes' SCSS sources.\n\nBecause the layout config partials are imported *after* Bootstrap, layout\nvariables can reference Bootstrap's. Reordering silently changes computed\nvalues rather than failing the build, which is why the banner is there.\n\n=== Directory map\n\n[cols=\"2,3\"]\n|===\n| `src/assets/sass/_config.scss` | Pulls in shared functions and mixins, then `global/init`.\n| `src/assets/sass/global/layout/` | Metronic shell regions: `base/`, `header/`, `brand/`, `aside/`, `content/`, `footer/`. Each has a `_config.scss` of variables and a partial of rules.\n| `src/assets/sass/global/integration/` | Third-party adapters. `frameworks/` covers Bootstrap, Material and AG Grid; `plugins/` holds 24 partials for jQuery-era widgets (`_jstree`, `_tinymce`, `_fullcalendar`, …) that the application does not use and which are inherited template weight.\n| `src/assets/sass/global/themes/` | `_dark-mode.scss` and `_new-egg.scss` — the source for the runtime theme CSS.\n| `src/assets/sass/custom/` | Project-specific: `_variables.scss`, `_typography.scss`, `_root.scss` (CSS custom properties), `_pyb-components.scss`, `ag-grid.scss`.\n| `src/assets/sass/pages/` | The two page-specific sheets that could not be component-scoped: marketplace setup and two-factor setup.\n|===\n\n`custom/_variables.scss` and `custom/_root.scss` are each a single `@import`\nthat re-exports the shared definitions from `@payability/pyb-ngx-components`,\nplus locally added variables such as `$lower-zoom-breakpoint: 300px`. Shared\ndesign tokens belong in the component library, not here.\n\n== Runtime themes\n\nThree CSS files ship as *assets* rather than being bundled, copied from\n`@payability/pyb-ngx-components/src/themes/` to `dist/themes/`:\n\n[cols=\"2,3\"]\n|===\n| `default-theme.css` | Standard Payability branding.\n| `new-egg-theme.css` | Newegg Capital white label.\n| `dark-theme.css` | Dark mode. Layered *on top of* whichever default theme is active, never on its own.\n|===\n\n`PybThemeService` (from `@payability/pyb-ngx-components`) fetches these with\n`HttpClient` and injects the text into a `<style>` element it keeps a handle on:\n\n[source,typescript]\n----\nprivate loadFile(applyDarkTheme?: boolean): void {\n    const url = `themes/${this.defaultThemeApplied}.css`;\n    this.http.get(url, { responseType: 'text' }).subscribe(result => {\n        this.attachStyles(result, 'defaultThemeElement');\n        if (applyDarkTheme) {\n            this.darkThemeApplied = true;\n            this.loadDarkModeFile();\n        }\n    });\n}\n----\n\nSwitching a theme means removing the old `<style>` element and fetching the new\nfile. `loadDarkModeFile()` additionally adds `pyb-client-provider--dark-mode` to\n`<body>`, which is the hook every dark-mode SCSS rule keys off.\n\n[NOTE]\n====\nTheme URLs are relative (`themes/default-theme.css`), which works because the\napplication is served from the domain root. It also means the request goes\nthrough Angular's `HttpClient` and therefore through the\nxref:security/interceptors.adoc[interceptor chain] — the theme fetch carries an\n`Authorization` header it does not need.\n====\n\n== Client providers (white labelling)\n\n`ClientProviderService` (`core/pyb/services/service-provider/`) decides which\nbrand the application is wearing. Two providers exist:\n\n[source,typescript]\n----\nexport enum PybClientProvider {\n    Default = 'supplier',\n    NewEgg  = 'new-egg',\n}\n\nexport enum PybClientHost {\n    Default = 'supplier4.payability.com',\n    NewEgg  = 'neweggcapital.payability.com',\n}\n----\n\nSelection uses two independent signals, either of which is sufficient:\n\n. **Host** — `window.location.host` contains `newegg`.\n. **User** — `user.onboardingTemplateCode === 'neweggcapital'`.\n\nThe host check applies before login, so the unauthenticated screens are already\nbranded. The user check runs on `UserLoaded` and on any change of\n`onboardingTemplateCode`, so a Newegg supplier who somehow arrives on the\ndefault host is switched over after authentication.\n\nEach provider is a strategy extending `ClientProviderAbstractStrategy`, and\ncarries just two properties:\n\n[source,typescript]\n----\nexport class ClientProviderNewEggStrategy extends ClientProviderAbstractStrategy {\n    protected readonly styleClass = 'pyb-client-provider--new-egg';\n    protected readonly theme      = PybTheme.NewEgg;\n}\n----\n\n`applyStyles()` swaps the body class and the theme, preserving dark mode across\nthe switch by tearing it down first and re-applying it after:\n\n[source,typescript]\n----\napplyStyles(): PybClientProviderStrategy {\n    const isDarkMode = localStorage.getItem('dark-mode-enabled');\n\n    if (isDarkMode) { this.themeService.resetDarkTheme(); }   // <1>\n\n    this.cleanUp();                                            // <2>\n    this.rd2.addClass(document.body, this.styleClass);\n    this.themeService.updateDefaultTheme(this.theme);\n    this.themeService.useDefaultTheme();\n\n    if (isDarkMode) { this.themeService.toggleDarkTheme(); }    // <3>\n\n    return this;\n}\n----\n<1> Remove the dark `<style>` element so it does not sit under the new base theme.\n<2> `cleanUp()` finds and removes whichever body class contains `pyb-client-provider`, so providers cannot accumulate.\n<3> Re-apply dark mode, which re-fetches `dark-theme.css`.\n\nBecause steps 2 and 3 are separate HTTP fetches, a provider switch with dark\nmode on produces a brief flash of the light base theme.\n\n=== Adding a provider\n\n. Add the enum members to `client-provider.model.ts` (both `PybClientProvider` and `PybClientHost`).\n. Add a `PybTheme` value and a theme CSS file in `@payability/pyb-ngx-components`.\n. Add a strategy class declaring `styleClass` and `theme`.\n. Register it in `ClientProviderFactory`.\n. Add the selection signal — host match, template code, or both — in `ClientProviderService.updateTemplate()`.\n\nNothing in this list is permission-gated; branding and access control are\nindependent. If a provider should also see a different feature set, add that to\n`applyPermissions()`, which the abstract strategy leaves as a no-op.\n\n== Dark mode\n\nDark mode is a user preference, on by default for nobody, and available to\nevery authenticated user — `PermissionsEnum.DARK_MODE` is in the default\npermission set (xref:security/permissions.adoc[Permissions]).\n\nThe toggle lives in the footer:\n\n[source,typescript]\n----\ntoggleTheme(): void {\n    if (this.theme.isDefaultTheme) {\n        localStorage.setItem('dark-mode-enabled', 'dark-mode-enabled');\n    } else {\n        localStorage.removeItem('dark-mode-enabled');\n    }\n    this.theme.toggleDarkTheme();\n}\n----\n\nThe preference is stored in `localStorage` under `dark-mode-enabled` — not in\nNgRX, and not on the server. It is per-browser and does not follow a supplier\nto another device. `ClientProviderService.init()` reads it during startup and\npasses it to `PybThemeService.init()` so the dark file is fetched in the same\npass as the base theme, avoiding a flash of light content.\n\n[WARNING]\n====\nThe stored value is the string `'dark-mode-enabled'`, and code reads it with a\ntruthiness check. Writing any other non-empty string to that key also enables\ndark mode. `FooterComponent` seeds its `FormControl` from\n`!!localStorage.getItem('dark-mode-enabled')` in `ngOnInit`, so the switch\nreflects reality only at construction — a theme change made anywhere else does\nnot move it.\n====\n\n=== Writing dark-mode styles\n\n`src/assets/sass/global/themes/_dark-mode.scss` nests everything under the body\nclass and uses CSS custom properties for colours:\n\n[source,scss]\n----\nbody.pyb-client-provider {\n    &--dark-mode {\n        .kt-header-mobile,\n        .kt-subheader,\n        .reconciliation-version-slide {\n            background-color: var(--black-5) !important;\n        }\n    }\n}\n----\n\nTwo consequences for new work:\n\n* Every rule needs `!important`, because it is competing with equally specific rules from the base theme that loaded first.\n* Any component that hard-codes a colour is invisible to dark mode. `ktSticky` sets `background-color: #fff` inline (xref:reference/utilities.adoc[Utility Reference]), and `styles.scss` hard-codes white on `.mat-snack-bar-container.toast-big`. Prefer `var(--…)` tokens.\n\nWhen adding a surface, check it in both modes before shipping. There is no\nautomated coverage for theming.\n\n== Component styles\n\n231 component stylesheets exist, and Angular's default emulated encapsulation\napplies. Two escape hatches are in use and both should be a last resort:\n\n[cols=\"2,3\"]\n|===\n| `ViewEncapsulation.None` | 9 components. Their styles are global — a selector collision is a bug anywhere in the app.\n| `::ng-deep` | 94 occurrences, almost all reaching into Material internals (`.mat-form-field-…`, `.mat-dialog-container`). Deprecated by Angular with no replacement; scope it under a host class so it cannot leak.\n|===\n\nThere are 86 `!important` declarations across component styles. Most exist to\nbeat Material or Metronic. Before adding another, check whether a\n`::ng-deep`-scoped rule at higher specificity does the job.\n\n== Responsive behaviour\n\nBreakpoints come from `BreakpointService` in `@payability/pyb-ngx-components`,\nwhich wraps the Angular CDK `BreakpointObserver`:\n\n[source,typescript]\n----\nbreakpoint.mobile          // or isMobile()\nbreakpoint.tablet          // or isTablet()\nbreakpoint.mobileOrTablet  // or isMobileOrTablet()\nbreakpoint.changed$        // BehaviorSubject<void>, emits on every change\n----\n\nComponents inject it as a `public readonly` so templates can read the getters\ndirectly. `changed$` is the signal to re-measure or re-render; with `OnPush`\nyou need to subscribe and mark for check rather than relying on the getters\nalone.\n\nSeveral features ship a distinct mobile component rather than a responsive one —\n`fee-report-mobile`, `daily-summary-mobile` — because AG Grid does not degrade\nusefully at phone widths. See xref:features/reports.adoc[Reports].\n\nThe shell's own responsive behaviour (aside collapse, mobile header) is\nMetronic's, driven by body classes from `HtmlClassService`. See\nxref:architecture/app-shell.adoc[Application Shell].\n\n== Icons\n\nThree sources, in rough order of preference:\n\nMaterial Icons font::\n`material-design-icons` is imported in `styles.scss`; use `<mat-icon>home</mat-icon>`.\n\nThe Metronic SVG icon set::\n`MaterialModule` registers the whole set once at startup:\n+\n[source,typescript]\n----\niconRegistry.addSvgIconSet(domSanitizer.bypassSecurityTrustResourceUrl('assets/media/icons'), iconOptions);\n----\n+\nIcons then resolve by name through `<mat-icon svgIcon=\"…\">`.\n\nIndividually registered SVGs::\nA handful of components call `addSvgIcon` in their own constructors, for example:\n+\n[source,typescript]\n----\niconRegistry.addSvgIcon('locked', sanitizer.bypassSecurityTrustResourceUrl('assets/media/icons/svg/General/Locked.svg'));\n----\n\n[WARNING]\n====\nPer-component registration writes into the *global* icon registry, so the name\nis claimed application-wide by whichever component was constructed last.\n`add-selling-info-modal` registers `locked` pointing at `Locked.svg`;\n`transfer-funds-modal` registers the same name `locked` pointing at `Star.svg`.\nOpening one modal changes the icon the other renders.\n\nRegister new icons in `MaterialModule` with a unique, prefixed name instead.\n====\n\n== Fonts\n\n`src/assets/sass/_fonts.scss` is a single re-export of the `@font-face`\ndeclarations in `@payability/pyb-ngx-components`, so font files are the\ncomponent library's concern, not this repo's. Production builds set\n`optimization.fonts: true`, which inlines and subsets them.\n\n== Where to make a change\n\n[cols=\"2,3\"]\n|===\n| One component's appearance | Its own `.scss`. Default encapsulation.\n| A shell region — header, aside, footer, subheader | `+src/assets/sass/global/layout/{region}/+`. Check both themes.\n| A colour used in more than one place | A CSS custom property in `@payability/pyb-ngx-components`, surfaced through `custom/_root.scss`.\n| Dark-mode-only correction | `global/themes/_dark-mode.scss`, nested under `body.pyb-client-provider--dark-mode`.\n| A new brand | `PybTheme` and a theme CSS file in the component library, plus a strategy here.\n| An AG Grid visual | `custom/ag-grid.scss` and `global/integration/frameworks/ag-grid/`.\n|===\n\nNothing in the styling layer is covered by tests, and Prettier does not reorder\nSCSS imports. Verify visually in light mode, dark mode, and — if the change\ntouches shared layout — on the Newegg host.\n"},{"component":"supplier-client","module":"ROOT","path":"development/pwa.adoc","title":"Progressive Web App","description":"Service worker registration and caching, the update banner, offline detection, and the iOS install prompt.","url":"https://docx.payability.com/supplier-client/development/pwa.html","navOrder":65,"source":"= Progressive Web App\n:description: Service worker registration and caching, the update banner, offline detection, and the iOS install prompt.\n\nThe dashboard is installable and works as a standalone app on mobile. Four\npieces make that up: the Angular service worker, a web app manifest, an\nin-app update prompt, and an iOS-specific install nudge — iOS has never\nsupported the `beforeinstallprompt` event, so Safari users have to be walked\nthrough *Share → Add to Home Screen* by hand.\n\nEverything here is **production only**. In development there is no service\nworker, no caching, no update banner and no install prompt, which means PWA\nbehaviour cannot be reproduced with `npm start`.\n\n== Registration\n\n`AppModule` registers the worker, gated on the build flavour:\n\n[source,typescript]\n----\nServiceWorkerModule.register('ngsw-worker.js', {\n    enabled: environment.production,\n    registrationStrategy: 'registerImmediately',\n}),\n----\n\n`registerImmediately` overrides Angular's default of waiting for app\nstability. The default never fires here because the application holds long-lived\ntimers and polling subscriptions, so without this the worker would never\nregister.\n\n`AppComponent` then starts the runtime behaviour behind the same flag:\n\n[source,typescript]\n----\nprivate initServiceWorker() {\n    this.environmentService.production && this.serviceWorkerService.init();\n}\n----\n\nNote the two checks read different things: `ServiceWorkerModule` reads\n`environment.production` directly, while `AppComponent` goes through\n`EnvironmentService` so tests can stub it. See\nxref:integrations/api-client.adoc[API Client] for how that service wraps the\ngenerated environment file.\n\n== Caching — `ngsw-config.json`\n\n=== Asset groups\n\n[cols=\"1,1,2\"]\n|===\n| Group | Install mode | Contents\n\n| `app`\n| `prefetch`\n| `index.html`, `manifest.webmanifest`, `pyb-glyph-dark.svg`, and all top-level `*.css` and `*.js`. Fetched during install so the shell works offline immediately.\n\n| `assets`\n| `lazy`\n| Everything under `/assets/**` plus fonts and images by extension. Cached on first request.\n\n| `blog-media`\n| `lazy`\n| `https://www.payability.com/wp-content/uploads/**/*.*` — images for the dashboard's blog widget, with `updateMode: lazy`.\n|===\n\n=== Data groups\n\n[cols=\"1,2\"]\n|===\n| `themes` | `/themes/**`, `performance` strategy, `maxSize: 100`, `maxAge: 3d`. This is what keeps the runtime theme CSS (xref:development/styling-and-theming.adoc[Styling and Theming]) from being re-fetched on every navigation.\n| The blog feed | `https://www.payability.com/wp-json/wp/v2/posts`, `performance`, `maxSize: 10`, `maxAge: 1d`, `ignoreSearch: false` so paginated requests cache separately.\n|===\n\n[WARNING]\n====\nThe second data group's name key is misspelled `\"mane\"` instead of `\"name\"`.\nThe Angular service worker config schema does not reject the unknown key, so the\ngroup is built with an undefined name. It still functions, but it cannot be\nreferenced by name and shows up unnamed in debugging output at\n`/ngsw/state`. Worth fixing, carefully — renaming a data group invalidates its\ncache for every installed client.\n====\n\nNo API responses are cached. Every request to the Payability API goes to the\nnetwork, which is deliberate: financial balances must never be served stale.\n\nA `performance` strategy means the cache is served first and the network is\nonly consulted after `maxAge` expires. For the theme files that is the right\ntrade — a three-day-old stylesheet is fine. Do not extend this pattern to\nanything a supplier would read as current.\n\n== Update flow\n\nProduction builds are hash-named (`outputHashing: \"all\"`), so a deploy produces\na new manifest the worker can detect. `ServiceWorkerService` polls for it and\nprompts rather than reloading underneath the user, who may be mid-form.\n\n[source,typescript]\n----\nprivate readonly fifteenMinutes = 1500000;\n\nprivate checkIsUpdateAvailable(): void {\n    this.updates.isEnabled &&\n        this.addSubscription(\n            interval(this.fifteenMinutes)\n                .pipe(takeWhile(() => !this.isUpdateBannerShowed))\n                .subscribe(() => this.updates.checkForUpdate().then()),\n        );\n}\n\nprivate listenForUpdate(): void {\n    this.addSubscription(\n        this.updates.available.pipe(takeWhile(() => !this.isUpdateBannerShowed)).subscribe(() => {\n            this.showUpdateBanner();\n        }),\n    );\n}\n----\n\nBoth streams complete once the banner has been shown, so a supplier who\ndismisses the prompt is not asked again for the life of the tab.\n\n[NOTE]\n====\n`1500000` milliseconds is 25 minutes, not the 15 the field name claims. The\ninterval is also the *only* trigger after startup — `SwUpdate` fires\n`available` on its own when the worker finds a new version, but the periodic\n`checkForUpdate()` is what makes it look.\n====\n\n`UpdateAppBannerComponent` renders inside the banner slot and does the reload:\n\n[source,typescript]\n----\nupdate(): void {\n    this.updates.activateUpdate().then(() => document.location.reload());\n}\n----\n\nThe banner is registered as a *component* banner rather than a message string,\nwhich is how the banner system supports an action button:\n\n[source,typescript]\n----\nprivate readonly updateBannerSettings: Banner = {\n    message: '',\n    component: UpdateAppBannerComponent,\n    type: BannerTypes.INFO,\n};\n----\n\nSee xref:business/dashboard-notifications.adoc[Dashboard Notifications] for the\nbanner queue itself.\n\n[WARNING]\n====\n`activateUpdate()` then `reload()` discards unsaved form state without warning.\nBecause the prompt can appear at any moment, a supplier halfway through a\nwithdrawal or onboarding step can lose their input by clicking it. There is no\nguard on the current route.\n====\n\n== Offline detection\n\nThe connection banner is driven by the browser's own events, not by the service\nworker:\n\n[source,typescript]\n----\nprivate isOnline$(): Observable<boolean> {\n    return merge(\n        of(navigator.onLine),\n        fromEvent(window, 'online').pipe(mapTo(true)),\n        fromEvent(window, 'offline').pipe(mapTo(false)),\n    );\n}\n----\n\nGoing offline shows a `WARNING` banner with `hideClose: true` so it cannot be\ndismissed while the condition holds; coming back online removes it.\n\n[NOTE]\n====\n`navigator.onLine` reports whether the device has *a* network interface, not\nwhether the API is reachable. A supplier on captive-portal Wi-Fi is \"online\"\nand will see request failures with no banner. Conversely the banner clears the\nmoment the interface returns, before the API is necessarily reachable.\n====\n\n`getConnectionBannerSettings()` rebuilds the settings object each time it shows\nthe banner, and `removeConnectionBanner()` passes the stored reference to\n`deleteBanner()` — so removal depends on the field still holding the same object\nidentity that was queued.\n\n== Manifest\n\n`src/manifest.webmanifest`, copied to the build output and linked from\n`index.html`:\n\n[source,json]\n----\n{\n    \"name\": \"Payability Dashboard\",\n    \"short_name\": \"Payability\",\n    \"theme_color\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\",\n    \"scope\": \"./\",\n    \"start_url\": \"/index.html\",\n    \"icons\": [\n        {\n            \"src\": \"assets/icons/icon-144x144.png\",\n            \"sizes\": \"any 512x512\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable any\"\n        }\n    ]\n}\n----\n\n[WARNING]\n====\nThe icon declaration is wrong in two ways. There is a single 144×144 file\ndeclared as `\"sizes\": \"any 512x512\"`, so Android will upscale it for the splash\nscreen and app drawer. And `purpose: \"maskable any\"` promises safe-zone padding\nthat a plain 144px glyph does not have, so the icon will be cropped inside\nAndroid's adaptive icon mask.\n\nFixing this means adding real 192, 384 and 512 pixel exports plus a separate\nmaskable variant with padding, and declaring each with its true size.\n====\n\n`theme_color` is `#ffffff` in the manifest but `#1976d2` in the `index.html`\n`<meta name=\"theme-color\">`. The two disagree; installed apps use the manifest,\nbrowser chrome uses the meta tag.\n\nAlso note `display: standalone` combined with `scope: \"./\"` and\n`start_url: \"/index.html\"` — the app uses hash routing\n(xref:routing/route-tree.adoc[Route Tree]), so every route lives under\n`/index.html#/…` and the scope covers them all.\n\n== iOS install prompt\n\n`index.html` carries the Apple-specific tags:\n\n[source,html]\n----\n<meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n<link rel=\"apple-touch-icon\" href=\"./assets/icons/icon-144x144.png\" />\n<link href=\"./assets/icons/icon-144x144.png\" rel=\"apple-touch-startup-image\" />\n----\n\nDetection is user-agent based, because Safari exposes no install API:\n\n[source,typescript]\n----\nisIOSMobileUser(): boolean {\n    const userAgent = navigator?.userAgent || navigator?.vendor;\n    return /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream;\n}\n\nisStandaloneMode(): boolean {\n    return 'standalone' in window.navigator && (window.navigator as any).standalone;\n}\n----\n\nIf the visitor is on iOS and *not* already running installed, a bottom-anchored\nmodal explains the Add to Home Screen steps:\n\n[source,typescript]\n----\nprivate showInstallAppPopupIfIOSUser(): void {\n    this.isIOSMobileUser() &&\n        !this.isStandaloneMode() &&\n        this.pybDialog.openMobile(PwaIOSInstallModalComponent, {\n            hasBackdrop: false,\n            panelClass: ['pyb-ios-pwa-install'],\n        });\n}\n----\n\n`hasBackdrop: false` keeps the page usable behind the prompt.\n`PwaIOSInstallModalComponent` is presentation only — a close button and\ninstructional copy.\n\n[NOTE]\n====\nSince iPadOS 13, Safari on iPad reports a desktop user agent, so\n`isIOSMobileUser()` returns `false` there and iPad users never see the prompt.\nChecking `navigator.maxTouchPoints > 1` alongside the platform string is the\nusual workaround.\n\nDismissing the modal is not remembered. Every full page load on an\nuninstalled iOS device shows it again.\n====\n\n=== iOS layout fix\n\nThe same detection drives a layout workaround in `BaseComponent`, because\nSafari's toolbars are excluded from `100vh`:\n\n[source,typescript]\n----\nprivate checkIfIOSUserAndFixPaddingIssue(): void {\n    if (this.serviceWorkerService.isIOSMobileUser() && !this.serviceWorkerService.isStandaloneMode()) {\n        this.renderer2.addClass(this.footer.nativeElement, 'pyb-ios-padding');\n        this.renderer2.setStyle(this.drawer.nativeElement, 'height', window.innerHeight + 'px');\n    }\n}\n----\n\nThe drawer height is pinned once at init, so it does not follow a rotation or a\ntoolbar collapse. See xref:architecture/app-shell.adoc[Application Shell].\n\n== Testing PWA behaviour\n\nNone of this runs under `npm start`. To exercise it:\n\n[source,bash]\n----\nnpm run build:prod\nnpx http-server dist -p 4200      # any static server; the SW needs a real origin\n----\n\nThen in Chrome DevTools:\n\n* *Application → Service Workers* — confirm `ngsw-worker.js` is activated.\n* *Application → Manifest* — installability and icon warnings.\n* `/ngsw/state` — the worker's own debug page, listing cached asset groups and the current manifest hash.\n* *Network → Offline* — verify the shell loads and the connection banner appears.\n\nTo exercise the update banner, build twice with a change between builds, load\nthe first, then swap the served output and call\n`navigator.serviceWorker.ready.then(r => r.update())` from the console rather\nthan waiting out the interval.\n\n[IMPORTANT]\n====\nA stale service worker is the most common cause of \"my fix isn't deployed\".\n`ngsw-worker.js` itself is served with no-cache headers, but if a deploy leaves\na mismatched manifest, clients keep serving the previous build from cache. Clear\nit with *Application → Clear storage* in DevTools, and check `/ngsw/state` for\nthe hash the client actually has.\n====\n"},{"component":"supplier-client","module":"ROOT","path":"development/adding-a-feature.adoc","title":"Adding a Feature","description":"End-to-end checklist for shipping a new page, a new permission, a new event or a new DocuSign flow.","url":"https://docx.payability.com/supplier-client/development/adding-a-feature.html","navOrder":66,"source":"= Adding a Feature\n:description: End-to-end checklist for shipping a new page, a new permission, a new event or a new DocuSign flow.\n\n== A new lazy-loaded page\n\n=== 1. Create the module\n\n[source]\n----\nsrc/app/views/pages/my-feature/\n├── my-feature.module.ts\n├── my-feature.component.ts / .html / .scss\n├── my-feature.service.ts        (provided by the module)\n└── my-feature.config.ts         (constants, route strings)\n----\n\n[source,typescript]\n----\ninclude::example$feature-module.ts[tag=module]\n----\n\n=== 2. Register the route string\n\nAdd it to `URLS_ROUTER` in `src/app/core/_config/urls-route.const.ts` — never\nhardcode a URL elsewhere.\n\n[source,typescript]\n----\nMY_FEATURE: { ROOT: 'my-feature' },\n----\n\n=== 3. Add the route\n\nIn `app-routing.module.ts`, as a child of the `BaseComponent` shell:\n\n[source,typescript]\n----\n{\n    path: 'my-feature',\n    loadChildren: () => import('../app/views/pages/my-feature/my-feature.module').then(m => m.MyFeatureModule),\n    canActivate: [/* guards */],\n}\n----\n\n=== 4. Add the menu entry\n\nIn `MenuConfig` (`core/_config/menu.config.ts`), with `page` from `URLS_ROUTER`,\na `translate` key and a `permission` if gated.\n\n=== 5. Add translations\n\nAdd a namespace to `src/assets/i18n/en.ts` and the `MENU.*` label.\n\n=== 6. Add the component\n\n* `ChangeDetectionStrategy.OnPush`\n* extend `Subscriber` so subscriptions are torn down for you\n* set the page title via `SubheaderService`\n* select from the store; dispatch a load action in `ngOnInit`\n\n[source,typescript]\n----\ninclude::example$feature-module.ts[tag=component]\n----\n\n=== 7. Add specs\n\nAt minimum a component spec and a service spec. Use the shared mocks from\n`@pyb-tests/*`.\n\n== A new NgRX slice\n\n[source]\n----\nsrc/app/core/pyb/features/my-feature/\n├── my-feature.actions.ts\n├── my-feature.reducer.ts     featureKey, State, initialState, createReducer + reducer wrapper\n├── my-feature.selectors.ts\n├── my-feature.effects.ts\n├── my-feature.module.ts      StoreModule.forFeature + EffectsModule.forFeature\n└── index.ts\n----\n\nThen register it in `FeaturesModule` — in **both** `imports` and `exports`.\n\nConventions:\n\n* actions named `[MyFeature] Description`,\n* `loading` / `loaded` / `error` flags if any guard or widget must wait,\n* selectors are the only read surface,\n* export a `reducer` function wrapper around `createReducer` for AOT.\n\nA full scaffold — action types, actions, reducer, selectors, effects and the\nregistration module:\n\n.Action types and actions\n[source,typescript]\n----\ninclude::example$ngrx-slice.ts[tag=enums]\n\ninclude::example$ngrx-slice.ts[tag=actions]\n----\n\n.Reducer\n[source,typescript]\n----\ninclude::example$ngrx-slice.ts[tag=reducer]\n----\n\n.Selectors\n[source,typescript]\n----\ninclude::example$ngrx-slice.ts[tag=selectors]\n----\n\n.Effects\n[source,typescript]\n----\ninclude::example$ngrx-slice.ts[tag=effects]\n----\n\n.Registration module\n[source,typescript]\n----\ninclude::example$ngrx-slice.ts[tag=module]\n----\n\nSee xref:state/overview.adoc[NgRX Overview].\n\n== A new permission\n\n. Append to `PermissionsEnum` — never insert in the middle; values are positional.\n. Add it to the tree in `permissions.const.ts`, nested if it has a parent.\n. Grant it from `getDefaultPermissions()` or `calculatePermissionsFromUserState()`.\n. Use the *full dotted name* in guards, `*ngxPermissionsOnly` and `MenuConfig`.\n. Add a case to `permissions.service.spec.ts`.\n\n[IMPORTANT]\n====\nA permission absent from `permissions.const.ts` never reaches\n`NgxPermissionsService`, so guards and directives cannot see it — even though the\nstore has it. `BETA` is the existing example of this.\n====\n\nSee xref:security/permissions.adoc[Permissions System].\n\n== A new analytics event\n\n. Add the member to the right enum in `event-types.enum.ts`, matching that enum's\n  naming style.\n. Inject `EventLoggerService` and call `track()` with the enum member.\n. Add it to xref:analytics/event-catalog.adoc[Event Catalog].\n\n[IMPORTANT]\n====\nSegment event *values* are effectively permanent — renaming one breaks every\ndownstream dashboard. Add a new member instead of editing an existing value.\n====\n\n== A new DocuSign flow\n\n. Add the member to `DocusignFlowEnum`.\n. Add `initialize<Name>Flow()` following the closest existing pattern —\n  advance-style flows require `event === SingingComplete` before acting; contract\n  flows run the handler on any event and branch inside.\n. Add `handle<Name>()` with the API confirmation and the business action.\n. Add the `case` to `initializeFlow()`.\n. Build the return URL with `generateRedirectUrl()`, targeting `dashboard` or\n  `product-offerings` — the only routes carrying `DocuSignGuard`.\n. Always leave via `redirectToDocusign()` so the replay flag is set.\n. End with `userStatusUpdateRequested()` if eligibility could change.\n\nSee xref:business/docusign.adoc[DocuSign Contract Flows].\n\n== A new multi-step flow\n\nUse the tasks engine when there are three or more steps, backward navigation,\nstate-dependent step order, or an external redirect. Otherwise open a dialog\ndirectly.\n\n. Add `FlowTask` implementations under `core/pyb/tasks/<domain>/` and register\n  them in `TasksModule`.\n. Add the flow under `core/pyb/flows/<domain>/` and register it in `FlowsModule`.\n. Seed the shared context with `setContextData()`.\n. Map `Done` → `runNext()`, `Cancel` → `runPrevious()`, `Terminated` → stop.\n\nSee xref:architecture/flows-and-tasks.adoc[Flows & Tasks Engine].\n\n== A new marketplace connector\n\n. Create `core/api-connectors/<marketplace>/`.\n. Extend `AbstractConnectorApi` with `bannerSuccessKey`, `bannerFailKey`,\n  `bannerComponent`, `modalComponent`, `storeKey` and `handleModalResult()`.\n. Add a banner implementing `AbstractConnectorBanner` and a modal implementing\n  `AbstractConnectorModal`.\n. Add `MODALS.AP_<NAME>.SUCCESS` / `.FAIL` translation keys.\n. Add a permission and grant it.\n. Initialize the connector in `ApiConnectorsService.init()` behind that permission.\n\nSee xref:integrations/marketplace-connectors.adoc[Marketplace API Connectors].\n\n== A new dashboard notification\n\n. Add the member to `DashboardNotificationType` with the API's string value.\n. Add the field to `DashboardNotifications` and map it in `fromDto()`.\n. Choose the surface:\n+\n--\nCTA card:: Add a strategy under `content/cta/cta-factory/cta-strategy/` and a\n`case` in `CtaFactory.getCtaConfig()`.\nAuto-opened modal:: Add a `subscribeTo…()` in `GlobalSubscriptionsService.init()`\nwith `take(1)`.\n--\n. If the notification is *accepted*, add an entry to `phraseKeyByType` in\n  `dashboard-notifications.effects.ts` — otherwise it silently shows the Pre\n  Instant Advance success copy.\n\nSee xref:business/dashboard-notifications.adoc[Dashboard Notifications & CTAs].\n\n== A new report\n\n. Create `views/pages/reports/<report>-page/`.\n. Extend `ReportBase`; implement `isDesktopReportVisible()`, `pageSize`,\n  `pageIndex` and `filterConfiguration`, plus `exportCSV()` / `applyFilter()` /\n  `clearFilter()` if the report offers them — the base throws\n  `NotImplementedError` otherwise.\n. Define the column set in a `*.config.ts` and provide it through a DI token from\n  `ReportsModule`.\n. Add the route to `reports-routing.module.ts` and the string to\n  `URLS_ROUTER.REPORTS`.\n. Add the menu entry with the right permission.\n. Provide a mobile layout via `ReportsMobileContentRowComponent`.\n\nSee xref:features/reports.adoc[Reports].\n\n== A new environment setting\n\n. Add it to `config/environment.template`.\n. Add it to every profile in `config/config.js` (`prod`, `dynamic`, `relative`).\n. Add it to `EnvironmentService` in `src/environments/environment.service.ts`.\n. Regenerate with `npm run pre-build:proxy`.\n\ninclude::partial$env-generated-warning.adoc[]\n\n== Pre-merge checklist\n\n* [ ] Route string in `URLS_ROUTER`, not hardcoded\n* [ ] Menu entry with the correct permission\n* [ ] All copy in `en.ts`, including any computed keys\n* [ ] Segment event for every user-facing action\n* [ ] `OnPush` change detection\n* [ ] Subscriptions cleaned up via `Subscriber` + `takeUntil`\n* [ ] Path aliases, no cross-module relative imports\n* [ ] Reducers pure — `storeFreeze` will catch mutations in dev\n* [ ] Specs for the branching logic\n* [ ] `npm run lint` and `npm test` clean\n* [ ] `userStatusUpdateRequested()` dispatched if eligibility can change\n* [ ] Documentation updated in this component, and the page added to `nav.adoc`\n"}]}