# Build a Chartsy integration — agent brief

Paste this whole file to your coding agent (Claude Code, Cursor, Codex, or similar) and
tell it which billing system to read from. It is written to be implementable without any
other document.

---

## Your task

Write two jobs that push billing data from `<MY BILLING SYSTEM>` into Chartsy's ingest API:

1. **A backfill script**, run once, that loads all history from `<START DATE>` to today.
2. **A sync job**, run on a schedule, that keeps Chartsy current.

Language and runtime: `<MY STACK>`. Read credentials from the environment; never hardcode.

## Connection

- Base URL: `https://dashboard.chartsy.app/api/v1`
- Auth header on every request: `Authorization: Bearer $CHARTSY_API_KEY` (keys start `csk_`)
- `Content-Type: application/json`
- Limits: **500 records per request**, **600 requests per minute** per key. A `429` means
  back off and retry.

## Non-negotiable rules

1. **Money goes in one of two fields, and the field name states the unit.**
   `"amount": "49.00"` is major units. `"amount_in_cents": 4900` is the same money in the
   currency's smallest unit. Send whichever my source already uses — if it exports cents,
   use the `_in_cents` form and do not divide anything yourself. Every money field has both
   spellings (`amount_due_in_cents`, `tax_in_cents`, `fee_in_cents`, `discount_in_cents`,
   `amount_off_in_cents`, `net_amount_in_cents`, …). Never send both for the same amount.
   An `_in_cents` field takes a **whole number only** — `"49.00"` there is rejected, not
   silently read as 49 cents. Note that "cents" follows the currency: JPY has no minor
   unit, so `{"currency": "JPY", "amount_in_cents": 4900}` is ¥4,900.
2. **All timestamps are ISO 8601 with a timezone.** `"2026-02-01T00:00:00Z"`.
3. **All currencies are 3-letter ISO 4217 codes**, uppercase.
4. **Every id is the id from my system**, not one Chartsy assigns. Posting the same id again
   updates that record. There is no mapping table to keep and no Chartsy id to store.
5. **Unknown fields are rejected with a 400.** Do not invent field names. If my source has
   data that does not fit a listed field, put it in `metadata` (any JSON object, queryable
   afterwards) — on every resource **except adjustments**, which take none.
6. **A payload is the record's complete state, not a patch.** Nested lists (`items`,
   `line_items`, `discounts`) replace what was stored. To remove a subscription add-on,
   re-post the subscription with only the remaining items.
7. **References must already exist.** A missing one is a `400` naming it — never a silently
   created stub. Hence the sending order below.
8. **Send a line's pre-tax amount, and the discount beside it.** Chartsy counts
   `amount - discount` per line. Use whichever field matches the data you have — the name
   states the convention, and exactly one may be sent:
   - `amount` (invoice line) / `total` (transaction item) — already pre-tax, Stripe's shape
   - `subtotal` — the same thing under Paddle's name
   - `amount_including_tax` / `total_including_tax` **plus** `tax` — gross, and Chartsy
     subtracts the tax
   Never pre-subtract the discount: put it in `discount` and Chartsy takes it off once.
   Getting this wrong is the most common reason a customer's MRR disagrees with their
   billing system by a small amount.
9. **A batch is atomic.** One bad record means nothing in that request is written. Fix and
   re-send the whole batch; re-sending is always safe because every write is an upsert.

## Sending order

```
discounts → customers → customer-businesses → products → prices
          → subscriptions → invoices → transactions → adjustments
```

Two shortcuts so the common case is one call: a subscription may carry a nested `customer`
object instead of `customer_id`, and a price may carry a nested `product` instead of
`product_id`. Send one or the other, never both.

## THE MOST IMPORTANT RULE: MRR comes from invoices

Chartsy computes MRR from **invoice line items**, not from subscription records. Each line
that carries a `price_id` and a service period contributes its amount, normalised to a
month, to every month that period covers.

Therefore:

- A subscription with no invoices behind it produces **zero MRR**.
- A month that no invoice period covers reads **zero** for that subscription.
- You must send **one invoice per subscription per billing period**, for every period that
  should appear on the chart — across the whole backfill, and again on every renewal.
  A monthly plan running since January 2024 needs ~20 invoices. An annual plan needs one
  per year: a single line with a 12-month period covers all twelve months.

A line only counts toward MRR if **all** of these hold:

| Requirement | Why |
|---|---|
| Invoice `status` is one of `paid`, `open`, `completed`, `past_due` | Other values (`draft`, `void`, `uncollectible`, anything unrecognised) are excluded |
| Invoice `amount_paid` is greater than `0` | An issued-but-unpaid invoice contributes nothing until it is paid |
| The line has a `price_id` | The price carries the billing interval |
| The line has `period_start` **and** `period_end` | These are the months the amount is spread across |
| The line is linked to a subscription, via `subscription_id` or a nested `subscription` | Unlinked lines count as revenue but not as recurring |

Cancelling is separate: a subscription with `status: "canceled"` and a `canceled_at` stops
counting from that timestamp, whatever invoices exist.

## Endpoints and fields

`metadata` (optional JSON object) exists on every resource below except adjustments. Fields not listed are
rejected.

### `POST /products/`
Required: `product_id`, `plan_name`.
Optional: `description`, `tax_category`, `active` (default true), `created_date`.
`plan_name` is what appears in every plan breakdown.

### `POST /prices/`
Required: `price_id`, `amount`, `currency`, and exactly one of `product_id` or nested `product`.
Optional: `interval` (`day`|`week`|`month`|`year`|`one_time`), `interval_count`
(`3` + `month` = quarterly), `price_name`, `usage_type`, `exchange_rate`, `active`, `created_date`.
One price per plan **per billing interval** — monthly and annual are two prices.
Only recurring intervals count toward MRR; `one_time` is stored and excluded.

### `POST /discounts/`
Required: `coupon_id`, `duration` (`once`|`repeating`|`forever`), `created_date`, and exactly
one of `amount_off` or `percent_off`. `amount_off` also requires `currency`.
`duration_in_months` is required when `duration` is `repeating`.
Optional: `code` (what customers type, e.g. `SAVE10`), `active`, `exchange_rate`.

### `POST /customers/`
Required: `customer_id`.
Optional: `email` (groups a person's subscriptions in lifetime-value charts), `name`,
`country` (drives geography charts), `city`, `currency`, `balance`, `delinquent`,
`default_payment_method`, `coupon_id`, `created_date`.

### `POST /customer-businesses/`
Required: `customer_business_id`, `customer_id`, `created_date`.
Optional: `name`, `company_number`, `tax_identifier`, `status`, `contacts`.

### `POST /subscriptions/`
Required: `subscription_id`; `status`; `created_date`; exactly one of `customer_id` or nested
`customer`; `items` (1–50).
`status` is one of `active`, `trialing`, `past_due`, `paused`, `unpaid`, `canceled`,
`incomplete`, `incomplete_expired`.
**`canceled_at` is required when `status` is `canceled`** — churn is measured from it, so
Chartsy refuses the record rather than under-report churn.
Optional: `started_at` (defaults to `created_date`), `current_period_start`,
`current_period_end`, `paused_at`, `trial_start`, `trial_end`, `cancel_at_period_end`,
`latest_invoice_id`, `default_payment_method`, `discounts`.

Each item: exactly one of `price_id` or nested `price`, plus optional `quantity` (default 1),
`amount`, `subscription_item_id`, `created_date`. Two items on the same `price_id` need
explicit `subscription_item_id` values to tell them apart.

### `POST /invoices/`
Required: `invoice_id`, `created_date`, `currency`, `status`, `amount_due`, `amount_paid`,
`amount_remaining`.
Optional: `customer_id`, `subscription_id`, `line_items` (up to 250), `tax`, `due_date`,
`period_start`, `period_end`, `next_payment_attempt`, `description`, `exchange_rate`,
`discounts`.

Each line item: `amount` and `currency` required. Optional `price_id`, `quantity`,
`discount`, `period_start`, `period_end`, `description`, `invoice_line_item_id`,
`prorated`, `exchange_rate`, and either `subscription_id` (reference an existing one) or
`subscription` (derive one — see below), never both.

Mark part-period charges `"prorated": true` so the amount is not taken as the
subscription's recurring amount, which would understate MRR for as long as it lives.
A line with no `price_id` is fine — its money still counts in the invoice total, it just
gets no per-plan attribution, which is correct for a one-off service line.

### Invoice-first (if my system has no concept of a subscription)

Put a `subscription` block on the line item instead of a `subscription_id`, and Chartsy
creates the subscription from the first invoice and extends it on each later one:

```json
{
  "invoice_id": "inv_001",
  "created_date": "2026-02-01T00:00:00Z",
  "currency": "USD", "status": "paid",
  "amount_due": "49.00", "amount_paid": "49.00", "amount_remaining": "0.00",
  "customer_id": "cus_0001",
  "line_items": [{
    "amount": "49.00", "currency": "USD", "price_id": "pr_pro",
    "subscription": {
      "subscription_id": "sub_0001",
      "current_period_start": "2026-02-01T00:00:00Z",
      "current_period_end":   "2026-03-01T00:00:00Z"
    }
  }]
}
```

The block requires `subscription_id`; the line must carry a `price_id`, and the invoice a
`customer_id`. It also accepts `status`, `started_at`, `canceled_at`, `trial_start`,
`trial_end`, `cancel_at_period_end` — setting `canceled_at` records churn with no separate
call. Send the same `subscription_id` next period and the same subscription is extended.

### `POST /transactions/`
Required: `transaction_id`, `amount`, `type`, `status`, `created_date`.
`type`: `payment`, `refund`, `credit`, `debit`, `adjustment`, `payout`, `invoice`.
`status`: `succeeded`, `failed`, `pending`, `draft`, `ready`, `billed`, `paid`, `completed`,
`canceled`, `refunded`, `past_due`.
Optional: `currency`, `customer_id`, `customer_business_id`, `subscription_id`, `invoice_id`,
`related_transaction_id` (the payment a refund reverses), `items`, `fee`, `tax`,
`net_amount` (what actually landed), `amount_refunded`, `description`,
`payment_method_type`, `payment_intent_id`, `available_on`, `disputed`, `dispute_id`,
`exchange_rate`, `discounts`, and `failure_code`/`failure_message` — **only valid when
`status` is `failed`**.

Each item: `price_id` and `total` required; optional `transaction_item_id`, `quantity`,
`discount`, `type`, `period_start`, `period_end`.

An invoice is what was billed; a transaction is the money arriving. Send both — they are
separate records, exactly as Chartsy stores them for Stripe.

### `POST /adjustments/` — refunds, chargebacks, credits
Required: `adjustment_id`, `transaction_id` (must already exist), `amount`, `currency`,
`adjustment_type` (`refund`|`chargeback`|`credit`|`other`), `status`
(`approved`|`pending`|`failed`), `adjustment_date`.
**`amount` must be negative for refunds and chargebacks**, positive for credits — revenue
queries add it to gross revenue, so a positive refund would increase reported revenue.
Chartsy rejects that with a `400`.
Optional: `reason`, `created_date`, `items` (each needs `transaction_item_id`). **No `metadata` on this resource** — sending one is a `400`.

### Reading back and deleting

```
GET    /api/v1/subscriptions/{my_id}/     # returns what Chartsy stored, in my ids
DELETE /api/v1/invoices/{my_id}/          # removes it and its nested children
```
Deleting leaves referenced records (customer, product, price) alone.

## Sending one or many

A single object, or an array of up to 500:

```bash
curl -X POST https://dashboard.chartsy.app/api/v1/subscriptions/ \
  -H "Authorization: Bearer $CHARTSY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{...}, {...}]'
```

Response: `{"count": 2, "results": [{"subscription_id": "sub_1", "id": 4021, "created": true}, ...]}`.
`201` when everything was new, `200` when anything was an update.

## Errors

| Status | Meaning | What to do |
|---|---|---|
| `400` | Validation failed; the body names each bad field, including inside nested objects | Fix the payload. Do not retry unchanged |
| `401` | Missing, unknown or revoked key | Stop; the key is wrong |
| `403` | Key is valid but the account cannot be written to (usually an ended trial) | Stop; tell the user |
| `404` | No such record on this key's account | Check the id |
| `409` | Another request was writing the same records; nothing was written | Sleep briefly and send the identical batch again |
| `429` | Rate limited | Back off and retry |
| `5xx` | Server error | Retry with exponential backoff; the batch was atomic, so nothing partial was written |

Log the full response body on any `400`. It names the exact field, e.g.
`{"canceled_at": ["Required when status is 'canceled'."]}`.

## What to build

**Backfill script** — one run, in the sending order above:
catalog and customers (including retired plans and churned customers, since old invoices
reference them) → subscriptions, current and historical → **invoices for every billing
period in the history** → transactions and adjustments.
Batch 500 at a time. Parallel workers are safe — but give each one its own slice of the
data (split by customer, for instance) rather than overlapping ranges, and handle `409` by
resending. History order does not matter: start dates only move earlier, billing
periods only move later, and only the newest invoice defines a subscription's current
contents, so replaying an old invoice cannot roll a live subscription backwards.

**Sync job** — on a schedule, sending whatever changed:
a new invoice for every billing period that started (miss these and MRR decays to zero as
the last periods run out), transactions as payments succeed or fail, changed subscriptions
including cancellations with `canceled_at`, and adjustments for refunds.
Because every write is an upsert keyed on my ids, re-send a trailing window — everything
created or changed since a couple of days before the last successful run — and let the
upserts absorb the overlap. Do not build a watermark table or track what was already sent.

## Before you tell me it's done

- [ ] Re-run the backfill script. It must be safe: same totals, nothing duplicated.
- [ ] `GET` one subscription, one invoice and one transaction back, and check the values
      match my source system.
- [ ] Confirm every recurring invoice line has `price_id`, `period_start` and `period_end`.
- [ ] Confirm every cancelled subscription has a `canceled_at`.
- [ ] Spot-check one amount against my billing system, and confirm which field you used:
      `amount` for major units, `amount_in_cents` for minor. A 100x error is the most
      common failure in this integration.
- [ ] Report the total counts pushed per resource, and any record the API rejected.
- [ ] Tell me to compare the MRR on the Chartsy dashboard with my own number. If Chartsy is
      low, the usual cause is invoices missing a price or a service period on their
      recurring lines.
