Skip to content
Browse developers

Proposals: the pricing rules that matter

How money is represented, why per-cadence figures are never added together, and what freezes when a proposal is sent.

Last updated

One rule governs everything below, and it is worth stating before anything else: render the totals the API returns; never compute one. The figures on a proposal are the binding consideration in an agreement a client signs, they feed the VAT a company reports, and they are arranged in a shape that is easy to add up incorrectly. Every figure you need is already on the response.

Money is a string

Amounts cross the wire as fixed two-decimal strings, never as JSON numbers.

{ "subtotal": "150.00", "vatAmount": "34.50", "totalAmount": "184.50" }

That is deliberate. A JSON number is a double, and a double cannot hold 184.50 exactly, so a value that survives one hop intact can come back a penny short after two. Keep it a string, or parse it into your own decimal type. Never into a float.

Send amounts the same way. All arithmetic on our side is done in integer pence or cent, and rounding is half-up at explicitly chosen points. An amount with more than two decimal places is refused rather than quietly rounded, with code: pricing-invalid and pricingCode: PRECISION_EXCEEDED; see Errors for the full list.

VAT is applied per bucket

A proposal carries a single VAT rate, and it is applied to each bucket of charges separately rather than to a merged sum. In every bucket:

  • subtotal is net of VAT, in both VAT-inclusive and VAT-exclusive pricing modes
  • vatAmount is the VAT on that bucket alone
  • totalAmount is gross, and subtotal + vatAmount equals it exactly

That last property holds because the three are computed together rather than derived from one another after rounding. If your own recalculation disagrees by a penny, yours is the one that is wrong.

Per-cadence figures are never added together

Charges are grouped by how often they recur, and each group carries its own subtotal and its own VAT:

{
  "oneOffAmount": "600.00",
  "oneOffVatAmount": "138.00",
  "oneOffTotalAmount": "738.00",
  "recurringGroups": [
    {
      "frequency": "MONTHLY",
      "multiplier": 12,
      "subtotal": "150.00",
      "vatAmount": "34.50",
      "totalAmount": "184.50",
      "annualisedSubtotal": "1800.00",
      "annualisedVatAmount": "414.00",
      "annualisedTotalAmount": "2214.00"
    },
    {
      "frequency": "QUARTERLY",
      "multiplier": 4,
      "subtotal": "400.00",
      "vatAmount": "92.00",
      "totalAmount": "492.00",
      "annualisedSubtotal": "1600.00",
      "annualisedVatAmount": "368.00",
      "annualisedTotalAmount": "1968.00"
    }
  ]
}

Do not add those groups together. A monthly charge plus a quarterly charge plus a one-off fee is not a number the client ever pays, and it does not describe any period. Present each group against its own cadence, exactly as the client will be invoiced.

The response also carries merged totalAmount and vatAmount fields. Those exist because database columns exist, they are used for sorting a list, and they are not displayable figures. Treat them as internal.

The cadences today are ONE_OFF, WEEKLY, FORTNIGHTLY, MONTHLY, BIMONTHLY, QUARTERLY, SEMI_ANNUALLY and ANNUALLY, and the set grows. Handle an unrecognised value through multiplier, which is the periods per year, rather than switching exhaustively on frequency.

Annualised figures are run rates

annualisedSubtotal, annualisedVatAmount and annualisedTotalAmount restate a cadence at an annual scale. Label them as run rates wherever you print them. They are not a contract value and not an amount that will be invoiced: multiplier is 52 for a weekly cadence and 26 for a fortnightly one, and the calendar does not agree, since roughly one year in six carries a fifty-third weekly run.

Note also that annualisedVatAmount is the VAT on the annual face value rather than the per-period VAT multiplied up. Both answers are correct; they answer different questions, and the per-period figure is the one that appears on an invoice.

Estimates make every figure a lower bound

A service line may be an estimate, with a range. The engine prices an estimate at its lower bound, and the upper bound is carried for display only and is never summed into anything.

Where a proposal contains any estimate line, hasEstimateLines is true, and every bucket total on that proposal is therefore a lower bound. Render those figures with a "from" label. The flag is proposal-wide rather than per bucket, so when it is set, label all of them. An unlabelled figure understates the engagement, and it is the headline number on both the proposal the client reads and the letter they sign.

What a proposal contains is frozen when it is sent

At the moment of sending we freeze the content, the resolved merge tokens and the pricing snapshot onto the proposal. From then on, changing a price in your service catalogue does not alter a proposal already sitting with a client, and neither does editing the template it came from. What the client sees is what they were sent.

The consequence for your integration: a sent proposal is not editable. A write against one is refused with 400 and code: proposal-not-editable, carrying the current status. An illegal state change, such as accepting something already declined, is 409 with code: proposal-transition-invalid and the from and to states. Neither is worth retrying. The way to correct a proposal that has gone out is to issue a new one.

Because acceptance runs a real signing ceremony, an accepted proposal has an envelope behind it, and PROPOSAL_ACCEPTED carries its envelopeId alongside the accepted fees. See Webhooks for that payload.

In short

Take oneOffTotalAmount and each row of recurringGroups and put them on the screen against their own cadence. Prefix them with "from" when hasEstimateLines is true. Say "run rate" next to anything annualised. Do not sum, do not re-derive, and do not parse a money string into a float.