# Transfer of Product Ownership - Backend Runbook (Accounting)

- **Owner:** PORT enablement team
- **Epic:** [PORT board](https://theorchard.atlassian.net/jira/software/c/projects/PORT/boards/1450)
- **Last Updated:** 2026-06-15

Scope: the Abacus / accounting portion of Product Transfer only - the `edit-attachments`
step of the transfer state machine and the contract-term changes it makes. It does not
cover content movement (`execute-content-transfer`), artist resolution
(`ensure-artists`), or analytics (`update-dim-tables`) except where they affect
accounting. The source of truth for everything here is the code on `upstream/master` of
`lambda-product-transfer`, `ows-royalties`, and `ows-project-manager`.

## Where the accounting step runs

The transfer is an AWS Step Functions state machine, `{env}-product-transfer`
(`terraform-infra/{env}/lambda-product-transfer/state-machine-definition.json`):

```mermaid
flowchart TD
    SE["start-execution lambda<br>(SQS FIFO, concurrency 1)"] --> S1["EnsureDestinationArtists"]
    S1 --> S2["ExecuteContentTransfer"]
    S2 --> P{{"PostTransfer (Parallel)"}}
    P --> D["UpdateDimTables"]
    P --> E["EditAttachments  ← accounting"]
    D --> F["FinalizeJob"]
    E --> F
    F --> DONE(["COMPLETED"])
    S1 -- error --> HE["HandleError"]
    S2 -- error --> HE
    P -- error --> HE
    F -- error --> HE
    HE --> FAIL(["FAILED"])
```

The accounting work is `EditAttachments`, which runs **in parallel** with `UpdateDimTables`
inside the `PostTransfer` state. The deployed machine starts at `EnsureDestinationArtists`
and has no accounting-run gate state. A `ValidateJob` precondition step is planned for the
future but is not yet deployed; today the preflight checks run inline at queue time in
`ows-project-manager`.

| Property | Value |
|---|---|
| Lambda | `{env}-lambda-product-transfer-edit-attachments` |
| Source | `lambda-product-transfer/lambda/edit-attachments/` |
| Timeout | 300s |
| Retry | 3 attempts, 10s interval, backoff 2 |
| Env vars | `ENVIRONMENT` only (service URLs resolved by `owsclient`); `LOGGER_LEVEL`, `SENTRY_DSN` optional |
| Auth | M2M (Auth0) via `owsclient` + `M2MTokenManager`; service identity carries the `transfer_operator` derived role for the PDP `project_transfer` resource |

There are no `OWS_ROYALTIES_URL` / `OWS_PROJECT_MANAGER_URL` / `OWS_MONEYHUB_URL`
environment variables. `owsclient` resolves service endpoints from `ENVIRONMENT`.

## What EditAttachments does

Handler: `lambda/edit-attachments/src/index.py`. Input event: `{"job_id": <int>}`. The
lambda moves the job's UPCs and ISRCs off the originating account's contract terms and onto
the destination account's contract terms:

```mermaid
sequenceDiagram
    participant L as edit-attachments
    participant PM as ows-project-manager
    participant R as ows-royalties

    L->>PM: GET /transfer/job/{id}
    PM-->>L: originating_vendor_id, destination_vendor_id
    L->>PM: GET /transfer/job/{id}/attachments
    PM-->>L: { upcs, isrcs }
    L->>R: DELETE /account/{origin}/contract-terms/attachments/bulk
    R-->>L: removed from source terms
    L->>R: GET /transfer-job/{id}/terms
    R-->>L: staged destination terms (+conditions)
    loop per product/track staged term
        L->>R: POST /account/{dest}/contract-terms/transfer-create
        R-->>L: creates destination term (idempotent, links it to the staged term)
    end
```

### External calls

| # | Service | Call | Purpose |
|---|---|---|---|
| 1 | ows-project-manager | `GET /transfer/job/{job_id}` | Read `originating_vendor_id`, `destination_vendor_id` |
| 2 | ows-project-manager | `GET /transfer/job/{job_id}/attachments` | Resolve `{ upcs, isrcs }` for the job |
| 3 | ows-royalties | `DELETE /account/{origin}/contract-terms/attachments/bulk` | Remove the UPCs/ISRCs from the originating account's terms |
| 4 | ows-royalties | `GET /transfer-job/{job_id}/terms` | Fetch the staged destination terms with their conditions |
| 5 | ows-royalties | `POST /account/{dest}/contract-terms/transfer-create` | One call per product/track staged term; creates a destination term with that term's own attachments and conditions |

How UPCs/ISRCs are resolved (`ows-project-manager`,
`project_manager/models/product_transfer_history.py`):

- **UPCs:** `SELECT DISTINCT CAST(r.upc AS CHAR)` joining `product_transfer_history -> releases`,
  skipping `r.upc = 0`. The canonical bigint `releases.upc` is used; there is no
  `display_upc` fallback.
- **ISRCs:** `SELECT DISTINCT t.isrc` joining `product_transfer_history -> track`, skipping
  null/empty. ISRCs are resolved live from `track` at request time; they are not stored in a
  history table.

How the destination add behaves (`ows-royalties`,
`royalties/logic/project_transfer_term.py` -> `create_destination_term`):

- For each staged term whose `term_type` is `product` or `track`, the lambda POSTs
  `transfer-create` with **that term's own** `attachments` (the UPCs/ISRCs configured on the
  staged term, not the job-wide set), plus its conditions, `attachment_relations`, and `name`.
  Staged terms of any other type are skipped, as are terms already created on a prior run.
- ows-royalties always creates a **new** `ContractTerm` for the staged term (it does not merge
  into an existing one), writes its `ContractTermCondition` rows (`commission = 100 - term_rate`),
  and records `destination_contract_term_id` back on the staged `project_transfer_term` row in the
  same transaction. The staged row is locked `FOR UPDATE`, and the call is idempotent on
  `project_transfer_term_id`: a retry returns the already-created term (200) instead of creating a
  duplicate. So each staged product/track term maps to exactly one destination term with its own
  rate.

How the source remove behaves (same file, `bulk_remove_from_contract_terms`):

- Finds active `product`/`track` terms for the originating account (join on
  `account_contract.account_id`). UPCs are removed from product-term `attachments`; ISRCs from
  track-term `attachments`.
- If a term's `attachments` becomes empty after removal, the term is **soft-deleted**
  (`deleted_at` set) instead of being left with an empty list. Otherwise the `attachments`
  list is trimmed. All changes commit in one transaction.

### Tables touched

| Table | DB | Change |
|---|---|---|
| `contract_term` | royalty_accounting (Abacus) | source `attachments` trimmed or soft-deleted; one new destination term created per staged term |
| `contract_term_condition` | royalty_accounting (Abacus) | created for each new destination term |
| `project_transfer_term`, `project_transfer_term_condition` | royalty_accounting (Abacus) | staged at queue time; `destination_contract_term_id` / `destination_contract_term_condition_id` written back on create |
| `project_transfer_job`, `product_transfer_history` | art_relations | read only (job + snapshot) |

## Idempotency

The step is safe to re-run (SFN re-drive or re-queue):

- **Resolve** (calls 1, 2, 4) is read-only.
- **Remove** of a UPC/ISRC that is already absent is a no-op for that term.
- **Add** is idempotent per staged term: once a term has a `destination_contract_term_id` the
  lambda skips it, and ows-royalties locks the staged row `FOR UPDATE` and returns the existing
  term on a retry. Re-running never creates a duplicate destination term.

## Error handling

`src/ows_response.py` maps responses to two exception types:

- **`TransientError`** - 5xx, timeout, or non-JSON body. The SFN retries (3 / 10s / backoff 2).
- **`PermanentError`** - 4xx or a malformed job (missing vendor ids). Not retried.

Any uncaught exception or exhausted retry is caught at the `PostTransfer` Parallel level and
routed to `HandleError`, which sets `project_transfer_job.status = 'FAILED'` and records
`failure_reason`. The execution ends in `JobFailed`.

## Customer accounting and the revenue cutoff

There is no customer-accounting work inside `edit-attachments`, and no moneyhub cache
invalidation (that was considered and dropped; downstream services own their own cache
lifetime). There is no `revenue_cutoff_date` filter in the dbt revenue models either.

Revenue attribution after a transfer is driven entirely by `FACTS.DIM_RELEASE_HISTORY`,
written by the `update-dim-tables` lambda (PORT-83) during the same `PostTransfer` state. For
each transferred release it closes the originating owner's open segment at the cutoff and
opens the destination's segment after it, keyed on statement period. Downstream
revenue-analysis breakdowns read effective ownership from that timeline. The cutoff value is
`project_transfer_job.revenue_cutoff_date`, set at queue time to the last day of the prior
month (overridable via `PATCH /transfer/job/{id}`).

## Operating and monitoring

- **CloudWatch logs:** `/aws/lambda/{env}-lambda-product-transfer-edit-attachments`. The
  handler logs each step and the resolved UPC/ISRC counts.
- **State machine:** the `{env}-product-transfer` execution in the Step Functions console.
  A failed `EditAttachments` shows the caught error on the `PostTransfer` state and the job
  ends in `JobFailed`.
- **Datadog / Slack:** the SFN `ExecutionsFailed` monitor (provisioned via
  `terraform-datadog//modules/step-function` in `sfn.tf`) alerts the project alerts channel.
- **Sentry:** unhandled exceptions report via the `SENTRY_DSN` integration.

### Common failures

| Symptom | Likely cause | Action |
|---|---|---|
| `PermanentError: ... missing valid originating/destination_vendor_id` | Job row incomplete | Inspect the `project_transfer_job` row; fix the data, re-queue |
| `ows-royalties returned 4xx` on transfer-create | Bad `contract_id` on a staged term, or auth/PDP role missing | Check the staged `project_transfer_term` rows and the `transfer_operator` role assignment |
| Repeated `TransientError` then FAILED | ows-royalties or ows-project-manager unavailable | Confirm the services are healthy, then re-queue the job |
| Destination terms missing after a "complete" run | All staged terms were a non-product/track type, or the resolved UPC/ISRC list was empty | Check the staged terms and the attachments resolution for the job |

## Rollback

There is no automated rollback. A re-transfer is a new job. To reverse the **accounting** side
of a transfer, undo the contract-term changes with a database PR against royalty_accounting
(standard Abacus DB PR process).

### 1. Find the old term by UPC (or ISRC)

Every UPDATE to `contract_term` writes the pre-change row into `contract_term_history` (the
`after_update_contract_term` trigger), so the state from just before the transfer - including the
`attachments` that still listed the transferred UPC and the original `deleted_at` - is preserved.
Search history by the UPC that moved:

```sql
SELECT *
FROM contract_term_history
WHERE JSON_UNQUOTE(attachments) LIKE '%195497950966%';
```

ISRCs work the same way (they live on the track terms). The row to restore from is the most
recent history row for that `contract_term_id` that still lists the value and has
`deleted_at IS NULL` - that is the snapshot from immediately before the transfer trimmed or
soft-deleted the term.

### 2. Restore the originating term from its history row

```sql
UPDATE contract_term ct
JOIN contract_term_history cth
  ON cth.contract_term_id = ct.contract_term_id
SET ct.attachments           = cth.attachments,
    ct.attachments_relations = cth.attachments_relations,
    ct.deleted_at            = cth.deleted_at,
    ct.deleted_by            = cth.deleted_by
WHERE ct.contract_term_id = 123456             -- the affected term
  AND cth.contract_term_history_id = 789012;   -- the pre-transfer snapshot from step 1
```

This puts the removed UPCs/ISRCs back and, if the term had been soft-deleted because its
attachments emptied, clears `deleted_at` to bring it back. The UPDATE itself writes a fresh
history row, so the audit trail is preserved. Restore `contract_term_condition` rows the same way
from `contract_term_condition_history` if any were affected.

### 3. Undo the destination side

The transfer created a **new** destination term per staged term, so reverse by soft-deleting
those terms (do not strip attachments). The created term ids are on the staged rows:

```sql
SELECT project_transfer_term_id, destination_contract_term_id
FROM project_transfer_term
WHERE job_id = 42 AND destination_contract_term_id IS NOT NULL;
```

Soft-delete each `destination_contract_term_id` (set `deleted_at` / `deleted_by` on the
`contract_term` and its `contract_term_condition` rows). Leave the `project_transfer_term` /
`project_transfer_term_condition` rows in place (they record what was queued).

Scope every statement to the affected `contract_id` / `account_id` / `job_id` so it cannot fan
out to unrelated terms. The revenue-attribution side (DIM_RELEASE_HISTORY) is owned by the
analytics step; coordinate with that owner if the move also needs reverting in analytics.
