# Rate Rounding Rules

A rate rounding rule is a named price convention for a workspace, such as "round down to the nearest 5, ending in
.99". A [product group](/catalog/product-groups/) that derives its rates from a parent can select one rule, and
its derived nightly, weekly and monthly prices then land on that convention instead of on the exact figure the
percentage produces.
Several groups can select the same rule, so editing the rule changes all of them.

## Key Concepts

### What a rule contains

A rule has a name and one currency-neutral definition:

| Part | Meaning |
| --- | --- |
| `stepUnits` | Whole major currency units between allowed prices, from 1 to 100. |
| `ending` | Where an allowed price sits relative to each multiple of the step. |
| `direction` | How the starting price is picked: `DOWN`, `UP` or `NEAREST`. A tie in `NEAREST` takes the higher price. |

The `ending` is either `FRACTION`, with `hundredths` from 0 to 99 (for example 99 for .99), or `ONE_UNIT_UNDER`,
one major unit below each multiple. `ONE_UNIT_UNDER` needs a step greater than 1 and takes no `hundredths`: leave
the field out when you send it. In responses `hundredths` is `null` for that ending.

A derived group never ends up at or above the price of its parent when its percentage is negative. In that case the
price steps to the allowed price below.

### Versions

Every rule has an integer `version`. A new rule starts at version 0. A change to the name or definition moves it
up by one. Changing a rule or deleting it needs the `expectedVersion` you last read. If someone else changed the rule
in between, the request fails with `RATE_ROUNDING_RULE_VERSION_CONFLICT` and nothing is written. Read the rule
again and retry.

### Selecting a rule on a group

A group selects a rule through its derived rate strategy, in one of three ways:

- `assignRoundingRule` selects a rule and keeps the group's percentage and parent as they are.
- `unassignRoundingRule` turns rounding off and keeps the percentage and parent.
- `rateStrategy.update` saves the percentage and the rule together. Leaving `roundingRuleId` out keeps the current
  selection. Sending `null` clears it.

A group and its rule must be in the same workspace. A new or restored derived strategy starts with rounding off.

### When prices change

Saving a selection on a group changes that group's prices straight away. Editing a rule's arithmetic or deleting a
rule reprices every group that selects it in the background. The response to those requests means the change was
saved, not that every price has been recalculated, so stored prices can briefly lag.

Deleting a rule clears it from every group that selected it, including archived groups. Those groups go back to
unrounded prices. Creating a rule or renaming it never changes prices.

Prices of existing bookings and quotes you have already issued are not recalculated when a rule is edited or deleted.
Only the catalog prices used for new bookings and new quotes change.

## GraphQL API

### Types

#### `RateRoundingRule`

| Field | Type | Description |
| --- | --- | --- |
| `id` | `ID!` | Identifier used to read, update, delete or assign the rule. |
| `name` | `String!` | Label for the rule, up to 150 characters. |
| `version` | `Int!` | Content revision used for optimistic concurrency. |
| `definition` | `RateRoundingDefinition!` | The arithmetic: `stepUnits`, `ending` and `direction`. |
| `usage` | `RateRoundingUsage` | `status` (`IN_USE` or `NOT_IN_USE`) and `productGroupCount`. |

`usage` is `null`, with an error, if it cannot be worked out. The rest of the rule is still returned. A missing usage
is never reported as zero groups.

#### `DerivedProductRateStrategy.roundingRule`

`roundingRule: RateRoundingRule` on a derived product group's rate strategy is the rule the group currently selects.
It is `null` when rounding is off.

### Queries

#### List the rules of a workspace

You need permission to read the workspace and permission to read its product groups, whichever route you use:
`awc.workspace`, as below, or a catalog route such as `catalog.productGroup(id).workspace.rateRoundingRules`. Rules
are ordered by name and then ID.
A page holds at most 25 rules and `first` defaults to 25. The `name` filter takes the usual string operators.

```graphql
query RateRoundingRules($workspace: ID!) {
  awc {
    workspace(id: $workspace) {
      rateRoundingRules(first: 25) {
        nodes {
          id
          name
          version
          definition {
            stepUnits
            ending {
              kind
              hundredths
            }
            direction
          }
          usage {
            status
            productGroupCount
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
        totalCount
      }
    }
  }
}
```

```json
{ "workspace": "workspace-id" }
```

An authorised caller with no rules gets an empty list. A caller without access gets an error and `null` for the
collection.

#### Get a rule

```graphql
query RateRoundingRule($id: ID!) {
  catalog {
    rateRoundingRule(id: $id) {
      id
      name
      version
      definition {
        stepUnits
        ending {
          kind
          hundredths
        }
        direction
      }
      usage {
        status
        productGroupCount
      }
    }
  }
}
```

```json
{ "id": "rule-id" }
```

A rule that does not exist, was deleted, or belongs to a workspace where the caller holds none of the workspace
permissions returns `null` with the error code `RATE_ROUNDING_RULE_NOT_FOUND`. The three cases look the same, so the
response never confirms that a rule exists. The same applies to `roundingRule` on a group's derived rate strategy,
to reading a rule's usage and to updating or deleting a rule.

#### Get the rule a group selects

```graphql
query ProductGroupRounding($id: ID!) {
  catalog {
    productGroup(id: $id) {
      rateStrategy {
        ... on DerivedProductRateStrategy {
          modifierPercentage
          roundingRule {
            id
            name
            definition {
              stepUnits
              ending {
                kind
                hundredths
              }
              direction
            }
          }
        }
      }
    }
  }
}
```

```json
{ "id": "product-group-id" }
```

### Mutations

Each mutation returns only an `id`. Read the rule or the group again to see the stored result.

#### Create a rule

```graphql
mutation CreateRateRoundingRule($input: CreateRateRoundingRuleMutationInput!) {
  catalog {
    rateRoundingRule {
      create(input: $input) {
        id
      }
    }
  }
}
```

```json
{
  "input": {
    "workspaceId": "workspace-id",
    "rule": {
      "name": "Nightly convention",
      "definition": {
        "stepUnits": 5,
        "ending": { "kind": "FRACTION", "hundredths": 99 },
        "direction": "DOWN"
      }
    }
  }
}
```

Creating a rule twice creates two rules. `clientMutationId` is accepted but not returned, and it does not
deduplicate.

#### Update a rule

Send only what changes. A `definition` replaces the whole arithmetic. An empty `rule` or an explicit `null` is
rejected.

```graphql
mutation UpdateRateRoundingRule($input: UpdateRateRoundingRuleMutationInput!) {
  catalog {
    rateRoundingRule {
      update(input: $input) {
        id
      }
    }
  }
}
```

```json
{
  "input": {
    "ruleId": "rule-id",
    "expectedVersion": 0,
    "rule": { "name": "Nightly convention revised" }
  }
}
```

#### Delete a rule

```graphql
mutation DeleteRateRoundingRule($input: DeleteRateRoundingRuleMutationInput!) {
  catalog {
    rateRoundingRule {
      delete(input: $input) {
        id
      }
    }
  }
}
```

```json
{ "input": { "ruleId": "rule-id", "expectedVersion": 1 } }
```

#### Select or clear a rule on a group

```graphql
mutation AssignRoundingRule($input: AssignProductGroupRoundingRuleMutationInput!) {
  catalog {
    productGroup {
      rateStrategy {
        assignRoundingRule(input: $input) {
          id
        }
      }
    }
  }
}
```

```json
{ "input": { "productGroupId": "product-group-id", "roundingRuleId": "rule-id" } }
```

```graphql
mutation UnassignRoundingRule($input: UnassignProductGroupRoundingRuleMutationInput!) {
  catalog {
    productGroup {
      rateStrategy {
        unassignRoundingRule(input: $input) {
          id
        }
      }
    }
  }
}
```

```json
{ "input": { "productGroupId": "product-group-id" } }
```

#### Save the percentage and the rule together

```graphql
mutation UpdateRateStrategy($input: UpdateProductGroupRateStrategyMutationInput!) {
  catalog {
    productGroup {
      rateStrategy {
        update(input: $input) {
          id
        }
      }
    }
  }
}
```

```json
{
  "input": {
    "productGroupId": "product-group-id",
    "rateStrategy": {
      "strategy": "DERIVE_FROM_PARENT",
      "derived": { "modifierPercentage": -5, "roundingRuleId": "rule-id" }
    }
  }
}
```

### Errors

| Code | Meaning |
| --- | --- |
| `VALIDATION_ERROR` | The name or definition is not valid, or a rule ID is empty. The error lists which input fields to fix. |
| `RATE_ROUNDING_RULE_VERSION_CONFLICT` | `expectedVersion` is out of date. Read the rule and try again. |
| `RATE_ROUNDING_RULE_NOT_FOUND` | The rule does not exist, was deleted, or is in a workspace where the caller has no access. While the permission service is unreachable, a rule can also read as not found. |
| `USER_NOT_ALLOWED_ERROR` | The caller has some access to the workspace but not the permission this needs, or the permission check returned an error. Reading and writing are separate permissions. |

Error messages are fixed text and never repeat what you sent.

## Relationships

- A rule belongs to one [Workspace](/workspaces/workspaces/)
- A rule is selected by derived [Product Groups](/catalog/product-groups/)
- A rule changes the prices held on [Products](/catalog/products/) for the groups that select it
