# Transfer of Product Ownership - Technical Design (Accounting)

- **Last Updated:** 2026-06-15
- **Scope:** the accounting portion only - the `edit-attachments` step of the transfer state
  machine, the contract-term changes it makes, and the revenue cutoff. Source of truth is the
  code on `upstream/master` of `lambda-product-transfer`, `ows-royalties`, `ows-project-manager`,
  and `terraform-infra`.

## Problem

When a project moves between accounts we keep the UPCs and avoid a redelivery, so the Abacus
contracts that pay out on that catalog do not move with it. The originating account's product
and track terms would keep paying on content it no longer owns, and the destination account
would have nothing attached. The accounting step rewrites the affected contract terms so payouts
follow ownership, and a separate analytics step splits revenue at a cutoff.

## Where it runs

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

```mermaid
flowchart TD
    SE["start-execution (SQS FIFO, concurrency 1)"] --> S1["EnsureDestinationArtists"]
    S1 --> S2["ExecuteContentTransfer"]
    S2 --> P{{"PostTransfer (Parallel)"}}
    P --> DT["UpdateDimTables  (revenue cutoff)"]
    P --> EA["EditAttachments  (contract terms)"]
    DT --> F["FinalizeJob"]
    EA --> F
    F --> DONE(["COMPLETED"])
    S1 -- error --> HE["HandleError"]
    S2 -- error --> HE
    P -- error --> HE
    F -- error --> HE
    HE --> FAIL(["FAILED"])
```

The two accounting-relevant steps, `EditAttachments` and `UpdateDimTables`, run in parallel
inside `PostTransfer`. The deployed machine starts at `EnsureDestinationArtists`; there is no
accounting-run gate state, and a `ValidateJob` precondition step is planned but not yet deployed
(preflight checks run inline at queue time in ows-project-manager).

## Contract-term changes (EditAttachments)

Lambda `lambda-product-transfer-edit-attachments`, input `{ "job_id": <int> }`. It authenticates
M2M via `owsclient` (the `transfer_operator` derived role on the PDP `project_transfer` resource)
and resolves service endpoints from `ENVIRONMENT` (no `OWS_*_URL` vars). Flow:

1. `GET {ows-project-manager}/transfer/job/{id}` - read `originating_vendor_id`,
   `destination_vendor_id`.
2. `GET {ows-project-manager}/transfer/job/{id}/attachments` - resolve `{ upcs, isrcs }`. UPCs are
   `CAST(releases.upc AS CHAR)` (skipping `upc = 0`); ISRCs are resolved live from `track`. There
   is no `display_upc` fallback.
3. `DELETE {ows-royalties}/account/{origin}/contract-terms/attachments/bulk` - remove the UPCs from
   the originating account's product terms and the ISRCs from its track terms. If a term's
   `attachments` empties, the term is soft-deleted rather than left empty.
4. `GET {ows-royalties}/transfer-job/{id}/terms` - fetch the staged terms with their conditions.
5. Per product/track staged term, `POST {ows-royalties}/account/{dest}/contract-terms/transfer-create`
   with that term's own `attachments`, conditions, `attachment_relations`, and `name`.

`transfer-create` always creates a new `contract_term` (it does not merge into an existing one),
writes its `contract_term_condition` rows (`commission = 100 - term_rate`), and records
`destination_contract_term_id` (and per-condition `destination_contract_term_condition_id`) back
on the staged row in the same transaction. It locks the staged row `FOR UPDATE` and is idempotent
on `project_transfer_term_id`, returning the existing term (200) on a retry. So each staged
product/track term maps to exactly one destination term with its own rate and attachment subset.

### Data model

| Table | DB | Role |
|-------|----|------|
| `project_transfer_job` | art_relations | the transfer job (status, vendors, `revenue_cutoff_date`) |
| `product_transfer_history` | art_relations | snapshot of the project's releases at queue time |
| `project_transfer_term`, `project_transfer_term_condition` | royalty_accounting | terms staged at queue time; carry `destination_contract_term_id` / `destination_contract_term_condition_id` once created |
| `contract_term`, `contract_term_condition` | royalty_accounting | the live Abacus terms: source terms trimmed/soft-deleted, one new destination term created per staged term |
| `contract_term_history`, `contract_term_condition_history` | royalty_accounting | append-only change history (via `after_update_*` triggers); the basis for rollback |

### Idempotency and errors

The step is safe to re-run (SFN re-drive or job re-queue). Resolution is read-only; removing an
absent attachment is a no-op; the destination create is skipped once
`destination_contract_term_id` is set and returns the existing term on a retry. ows-royalties 5xx,
timeouts, and non-JSON bodies raise `TransientError` (SFN retries 3 / 10s / backoff 2); 4xx and a
malformed job raise `PermanentError`. Any exhausted retry is caught at the `PostTransfer` level and
routed to `HandleError`, which sets the job `FAILED` with a `failure_reason`.

## Revenue cutoff (customer accounting)

Revenue attribution after a transfer is driven entirely by `FACTS.DIM_RELEASE_HISTORY`, written by
the `update-dim-tables` lambda (PORT-83) in 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, so the originating account keeps revenue booked through the cutoff and
the destination gets everything booked after, with no double counting. The cutoff is
`project_transfer_job.revenue_cutoff_date` (last day of the prior month, set at queue time).

### Deliberate no-ops

- **dbt cutoff filter:** the originally planned `revenue_cutoff_date` filter on `revenue_distro_dbt`
  / `revenue_nr_dbt` does not ship. Attribution comes from `DIM_RELEASE_HISTORY`, so the dbt revenue
  models need no transfer-specific filter. NR is out of scope.
- **Moneyhub cache invalidation:** considered and dropped; downstream services own their cache
  lifetime.

## Rollback

There is no automated rollback; a re-transfer is a new job. The accounting side is reversed with a
database PR against royalty_accounting, using `contract_term_history` /
`contract_term_condition_history` to find the pre-transfer state, restoring the originating terms,
and soft-deleting the destination terms the transfer created. See the
[backend runbook](../../runbooks/transfer-of-product-ownership/backend.md) for the procedure and
example SQL.

## Frontend

The Abacus "Transfer Project" screen queues a job (`project_transfer_job`) and stages the chosen
terms (`project_transfer_term` via `POST /transfer-job/{id}/terms`). Label mode captures the
destination contract's label-term conditions; custom mode stages product/track terms with their own
UPC/ISRC subsets and rates. See the
[frontend runbook](../../runbooks/transfer-of-product-ownership/frontend.md).
