Subscription Reporting & Attribution

Three read-only endpoints that tell you, for any window you choose, exactly how much of your subscription revenue is attributable to each recipient you nominated — aggregated for a payout run, per subscription for a member enquiry, and per renewal for a single reconciled line. Every figure comes from renewals that actually settled.

Read this first: a report, not a remittance

Invo settles to you, and only to you. The recipients named in these responses are your users under your agreements. Invo has never identity-verified them, has no contract with them, and has no tax-reporting relationship with them — so Invo does not pay them and never will. You pay your own recipients, out of your own settlement, on your own rails.

This is not a footnote in the docs: it ships in the payload. Every response from every endpoint on this page carries an attribution_notice block, and settled_by_invo: false is restated at every nesting level — on the totals, on each recipient, on each renewal line, on each attribution object, and on each refund event. It is written that way so the statement survives being pasted into an email, a spreadsheet, or your own accounting system.

What these endpoints give you is the data that makes paying your recipients a lookup rather than a reconstruction. No money has moved to anyone named in a response.

Authentication

All three endpoints are server-to-server, authenticated with your secret key. They are read-only — no endpoint here writes anything, and none of them can move money.

X-Game-Secret-Key: your_secret_key_here

Never call these from a client. A response contains recipient emails and your revenue figures. Results are scoped to your game at every level — a subscription id or a period id from another tenant returns 404, never data.

1. The four correctness properties

Each of these closes a specific way a report of this kind normally goes wrong. They are worth understanding before you build a payout cycle on top of the numbers.

1. Figures derive from settled renewals, and are never recomputed from a rate

A stored percentage is not a historical payout. Invo does not keep a rate and multiply it by a past amount at read time. The attributed amount you get back is the snapshot recorded at the moment the renewal settled — byte for byte the same object that already reached you on the subscription.renewed webhook.

So if you change a recipient's rate in March, February's report does not move. The two detail endpoints do show today's configured rate for convenience, in a current_revenue_share block — and that block is explicitly stamped is_payout_basis: false and applies_to: "future_renewals_only" so it can never be mistaken for one. It is deliberately absent from the aggregate report, where sitting next to a total it would invite exactly the recompute this design exists to prevent. On a renewal line the historical rate is named percent_at_renewal, again so the two cannot be confused.

2. The non-settlement flag is on every response

attribution_notice is present on every response from every endpoint here, including empty ones. It carries settled_by_invo: false, invo_has_paid_no_recipient: true, document_type: "attribution_report", not_a_remittance_advice: true, payer: "partner", and a plain-English statement saying the same thing in a sentence.

3. Figures are net of reversals, with the breakdown shown

Each recipient row carries renewal_count, gross_attributed_usd, reversed_attributed_usd and net_attributed_usd. The net is the figure you pay. The gross and the reversal are both emitted alongside it so nothing has to be inferred from a difference.

A renewal that settled and was fully refunded inside the same window reports as a net-zero line, not as a negative. It appears in the window's gross and its reversal, so it cancels itself: you are never told to claw back money you were never told to pay in the first place. Those lines carry nets_to_zero_in_window: true plus an explanatory note, and are counted in totals.renewals_settled_then_fully_reversed so you can see why one renewal shows up on both sides.

4. The window is keyed on when money settled — not when a period opened

A renewal for a July cycle that actually settled on 2 August belongs to the August payout run, because August is when you received the revenue you are paying out of. No straddling periods to reason about, no partial cycles to apportion.

This is also what makes a closed window reproducible. A renewal that settled in July stays in July's gross forever, whatever happens to it afterwards, and every reversal is reported in the window its refund actually happened in. Run July in August, pay your recipients, re-run July in December — you get the same numbers and can reconcile against what you paid.

There are exactly two revenue legs

Invo, and you. A recipient is never a third leg — not in this phase and not in a later one. invo_fee_usd is what the platform kept; partner_revenue_usd is what you were left with; the attributed figure is a slice of your remainder that you have told us to attribute, calculated as a percentage of the amount after the Invo fee. Invo's own fee is retained on a refund, so it has no reversed counterpart.

2. Aggregated attribution — the payout run

GET

/api/subscriptions/reporting/attribution

This is the endpoint you run to pay your recipients for a month. It folds every renewal that settled in the window into one row per recipient, and every refund event that happened in the window into that row's reversal.

Query parameters

FieldTypeReq.Notes
startstringyesInclusive. ISO 8601 date (2026-07-01, read as 00:00:00Z) or full datetime. A naive datetime is stamped UTC rather than guessed at.
endstringyesExclusive. Same formats. Must be after start, and no more than 400 days later.
recipient_player_idintnoRestrict to one recipient. Note the platform-wide figures in totals are accumulated before this filter.
subscription_idstringnoRestrict to one subscription. Max 50 chars.
limitintnoPagination over recipients, not renewals. Default 50, silently clamped to a maximum of 100. Must be ≥ 1.
offsetintnoDefault 0. Must be between 0 and 100000.
Pass an explicit offset if you report on a local month. An offset in the timestamp is honoured rather than normalised away, so start=2026-07-01T00:00:00-07:00 means midnight Pacific, not midnight UTC. Totals are never paginated — only the recipient list is. Sorting is by net attributed descending, with recipient id ascending as a stable tiebreaker, so a recipient cannot appear on two pages or on none.

Response (200)

{
  "status": "success",
  "attribution_notice": {
    "settled_by_invo": false,
    "invo_has_paid_no_recipient": true,
    "document_type": "attribution_report",
    "not_a_remittance_advice": true,
    "payer": "partner",
    "statement": "INVO has not paid any recipient named in this report and will not. ..."
  },
  "report": {
    "type": "founder_attribution",
    "game_id": "97288428282",
    "generated_at": "2026-08-01T10:00:03+00:00",
    "window": { "start": "2026-07-01T00:00:00+00:00",
                "end":   "2026-08-01T00:00:00+00:00",
                "end_exclusive": true }
    // also: "filters", a "derivation" block naming in words where each figure
    // came from, and a "known_limits" block restating the caps in section 5.
  },
  "totals": {
    "renewals_settled": 1284,
    "settled_gross_usd": "12827.16",
    "invo_fee_usd": "833.77",
    "partner_revenue_settled_usd": "11993.39",
    "partner_revenue_reversed_usd": "56.14",
    "partner_revenue_usd": "11937.25",
    "renewals_with_attribution": 1190,
    "renewals_without_attribution": 94,
    "unattributed_reasons": { "no_revenue_share_configured": 94 },
    "gross_attributed_usd": "1112.40",
    "reversed_attributed_usd": "5.61",
    "net_attributed_usd": "1106.79",
    "renewals_settled_then_fully_reversed": 3,
    "refund_events_in_window": 7,
    "reversals_without_receipts": 0,
    "recipient_count": 46,
    "settled_by_invo": false
  },
  "recipients": [
    {
      "recipient": {
        "player_id": 40219,
        "resolved": true,
        "player_email": "founder@example.com",
        "player_name": "Kara V.",
        "identity_id": "f3a1b8c0d4e5..."
      },
      "renewal_count": 118,
      "subscription_count": 118,
      "gross_attributed_usd": "110.24",
      "reversed_attributed_usd": "0.93",
      "net_attributed_usd": "109.31",
      "first_paid_at": "2026-07-01T09:02:11+00:00",
      "last_paid_at": "2026-07-31T22:40:55+00:00",
      "settled_by_invo": false,
      "periods": [ /* up to 500 evidence lines — see section 3 */ ],
      "periods_truncated": false,
      "reversals": [ /* refund evidence, same 500 cap */ ],
      "reversal_count": 1,
      "reversals_truncated": false
    }
  ],
  "pagination": { "total_count": 46, "limit": 50, "offset": 0, "has_more": false }
}

totals

FieldTypeNotes
renewals_settledintRenewals that settled in the window, whatever their status is today.
settled_gross_usdstringTotal billed, from each renewal's charge-time price snapshot.
invo_fee_usdstringWhat the platform kept. Never reversed by a refund.
partner_revenue_settled_usdstringYour revenue as booked in this window. Fixed at settlement; does not move afterwards.
partner_revenue_reversed_usdstringYour revenue clawed back by refunds that happened in this window.
partner_revenue_usdstringThe difference — your revenue movement for the window, not a running balance.
renewals_with_attribution / renewals_without_attributionintHow many settled renewals carried an attribution.
unattributed_reasonsobjectCounts by reason — see the table below. Reported rather than silently dropped.
gross_attributed_usdstringAttributed before reversals, across all recipients.
reversed_attributed_usdstringAttribution reversed by refund events in this window.
net_attributed_usdstringGross minus reversed. The whole run's payout figure.
renewals_settled_then_fully_reversedintRenewals that settled and were fully refunded inside this same window — the net-zero lines.
refund_events_in_windowintIndividual refund events counted, not renewals refunded.
reversals_without_receiptsintLines that fell back to the coarse reversal path. Non-zero means limitation 1 in section 5 applies to that many lines.
recipient_countintDistinct recipients in the window, before pagination.
settled_by_invoboolAlways false.

unattributed_reasons keys

ValueMeans
no_revenue_share_configuredThe renewal ran and no share existed for that subscription at the time. Nobody earned anything — a real answer, not a defect.
spend_order_not_foundThe renewal's reconciliation record could not be reached. Investigate before paying anything off that line.
attribution_not_recordedThe record exists but carries no attribution object.
attribution_malformedAn attribution object exists but could not be read. Contact us if you see this.

recipients[]

FieldTypeNotes
recipient.player_idintYour player, in your game. Recipients are always same-game players.
recipient.resolvedboolfalse when the player record could not be found. A real answer, not an error — the row is still reported, with the identity fields null, so you investigate it rather than silently miss it.
recipient.player_email, player_name, identity_idstring | nullIdentity for your own payout run.
renewal_count, subscription_countintAttributed renewals, and how many distinct subscriptions they came from.
gross_attributed_usd, reversed_attributed_usd, net_attributed_usdstringnet_attributed_usd is the number to pay.
first_paid_at, last_paid_atstring | nullSettlement bounds of this recipient's renewals inside the window.
periods[]arrayInline evidence — the renewal lines behind the total, shape as in section 3. Capped at 500 per recipient.
periods_truncatedbooltrue when the evidence list hit the cap. The totals are never truncated — only this list. Use the per-subscription endpoint for the rest.
reversals[], reversal_count, reversals_truncatedarray, int, boolThe refund evidence, same 500 cap. See the table below.

recipients[].reversals[]

FieldTypeNotes
period_id, subscription_id, period_seq, statusint, string, int, stringWhich renewal was refunded.
paid_at, settled_in_this_windowstring, boolWhen it settled, and whether that was inside this window.
amount_usd, originally_attributed_usdstringWhat it was billed and what was originally attributed.
refunded_to_date_usd, latest_refund_atstringCumulative, for reference only. The reversal below is not derived from them.
reversed_attributed_usdstringThis window only, summed from the refund events below.
partner_revenue_reversed_usdstringYour revenue reversed by those same events.
refund_events[]arrayOne entry per individual refund: refunded_at, refunded_amount_usd, cumulative_refunded_before_usd, cumulative_refunded_after_usd, reversed_attributed_usd, partner_revenue_reversed_usd, partner_revenue_reversal_basis, is_full_refund, settled_by_invo.
reversal_basisstring"refund_receipts" — the normal, exact, per-event path. "period_row" — the coarse fallback; see limitation 1.
nets_to_zero_in_window, notebool, string | nullSettled and fully refunded inside this window: counted in gross and subtracted again, so pay nothing and claw nothing back.

Errors

Statuserror_codeCause
400WINDOW_INVALIDstart or end missing, unparseable, or end not after start.
400WINDOW_TOO_LONGThe range exceeds 400 days.
400WINDOW_TOO_LARGEMore than 20,000 settled renewals (body carries settled_renewals and max_renewals) or more than 20,000 refunded renewals (reversals, max_rows) in the range. See limitation 2.
400PAGINATION_INVALIDlimit/offset not integers, limit < 1, or offset outside 0…100000.
400RECIPIENT_INVALIDrecipient_player_id is not an integer.
400SUBSCRIPTION_ID_INVALIDsubscription_id longer than 50 characters.
400INVALID_FIELD_VALUEA parameter value the database rejected.
429rate_limit_exceededCarries retry_after in the body and a Retry-After header.
500INTERNAL_ERRORThe report could not be generated. Retry; contact us if it persists.

3. One subscription's attribution history

GET

/api/subscriptions/reporting/subscriptions/{subscription_id}/attribution

Every cycle of one subscription: what was billed, how it split, and what was attributed to whom. The endpoint to reach for when a member or a recipient asks a question about one membership.

Query parameters

FieldTypeReq.Notes
statusstringnoComma-separated cycle statuses. Any of pending, paid, failed, forgiven, refunded. Default: all.
limit, offsetintnoPagination over cycles. Same rules as section 2 — default 50, max 100.

Cycles that were never paid are included by default and carry attribution: null. A failed or forgiven cycle earned nobody anything, and showing it is how you see a gap in a member's billing history rather than assuming one never existed. Cycles are returned newest first.

Response (200)

{
  "status": "success",
  "attribution_notice": { /* as in section 2 */ },
  "subscription": {
    "subscription_id": "SUB_1786982400_A7K2M9QX",
    "status": "active",
    "item_id": "guild_membership_gold",
    "item_name": "Gold Guild Membership",
    "current_amount_usd": "9.99",
    "interval": "month",
    "interval_count": 1,
    "created_at": "2026-04-01T09:00:00+00:00"
  },
  "current_revenue_share": {
    "recipient_player_id": 40219,
    "percent": "10.00",
    "applies_to": "future_renewals_only",
    "is_payout_basis": false,
    "settled_by_invo": false,
    "note": "Current configuration. ... Changing this percent does not change any past attribution.",
    "updated_at": "2026-06-02T11:20:00+00:00"
  },
  "lifetime_totals": {
    "renewals_settled": 4,
    "settled_gross_usd": "39.96",
    "invo_fee_usd": "2.60",
    "partner_revenue_settled_usd": "37.36",
    "partner_revenue_reversed_usd": "2.81",
    "partner_revenue_usd": "34.55",
    "renewals_with_attribution": 4,
    "gross_attributed_usd": "3.72",
    "reversed_attributed_usd": "0.28",
    "net_attributed_usd": "3.44",
    "recipient_player_ids": [40219],
    "settled_by_invo": false
  },
  "periods": [
    {
      "period_id": 55129,
      "subscription_id": "SUB_1786982400_A7K2M9QX",
      "period_seq": 4,
      "item_id": "guild_membership_gold",
      "item_name": "Gold Guild Membership",
      "status": "paid",
      "period_start": "2026-07-01T09:00:00+00:00",
      "period_end": "2026-08-01T09:00:00+00:00",
      "paid_at": "2026-07-01T09:02:11+00:00",
      "amount_usd": "9.99",
      "invo_fee_usd": "0.65",
      "partner_revenue_usd": "6.53",
      "partner_revenue_settled_usd": "9.34",
      "partner_revenue_reversed_usd": "2.81",
      "partner_leg_reversed": true,
      "funding": {
        "amount_coins": "99.90",
        "balance_applied_coins": "40.00",
        "card_charged_usd": "5.99"
      },
      "refund": { "refunded_amount_usd": "3.00",
                  "refunded_at": "2026-08-14T16:05:02+00:00" },
      "transaction_id": "TXN_1786982400_K3P9",
      "mint_order_id": 884212,
      "attribution": {
        "recipient_player_id": 40219,
        "percent_at_renewal": "10.00",
        "base_usd": "9.34",
        "attributed_amount_usd": "0.93",
        "settled_by_invo": false,
        "recipient": { "player_id": 40219, "resolved": true,
                       "player_email": "founder@example.com",
                       "player_name": "Kara V.",
                       "identity_id": "f3a1b8c0d4e5..." }
      },
      "attribution_unavailable_reason": null,
      "settled_by_invo": false,
      "reconciliation": {
        "checked": true,
        "expected_base_usd": "9.34",
        "recorded_base_usd": "9.34",
        "base_matches_settled_fee": true
      }
    }
  ],
  "pagination": { "total_count": 5, "limit": 50, "offset": 0, "has_more": false }
}
FieldTypeNotes
subscription.current_amount_usdstringToday's price. What any given cycle was billed is on that cycle's amount_usd.
current_revenue_shareobject | nullToday's configured rate, explicitly is_payout_basis: false. null if no share is configured.
lifetime_totalsobject | nullAcross every settled cycle of this subscription, not just the page. null in the pathological case that a subscription exceeds the 20,000-renewal cap. Uses the same settled set as the aggregate report, so the two reconcile.
periods[].amount_usdstringThe price snapshot taken when this cycle was charged.
periods[].partner_revenue_usdstringNet of any reversal — a per-renewal line is a point read, so the net is what you actually hold for it. partner_revenue_settled_usd and partner_revenue_reversed_usd are both alongside it, so the difference never has to be inferred.
periods[].fundingobjectamount_coins and balance_applied_coins are coins, labelled as such and never converted; card_charged_usd is USD. Do not compare across the two.
periods[].transaction_id, mint_order_idstring, intInvo's own ledger handles, for correlating against the webhook you already received.
periods[].attribution.percent_at_renewalstring | nullThe rate in force at that renewal — named so it cannot be confused with today's configured percent.
periods[].attribution.base_usdstringThe amount the percentage was applied to: the billed amount after the Invo fee.
periods[].attribution_unavailable_reasonstring | nullOne of the four values in section 2, when attribution is null.
periods[].reconciliationobjectA check, not a substitute: it re-derives the base from the canonical revenue rows and reports whether it matches the recorded one (base_matches_settled_fee). attributed_amount_usd stays the recorded snapshot even when this is false — re-deriving it would be exactly the recompute this design avoids. Tell us if you ever see false.

Errors

Statuserror_codeCause
400PAGINATION_INVALIDBad limit or offset.
400STATUS_INVALIDAn unknown value in status. The body names the offending values.
400INVALID_FIELD_VALUEAn unusable subscription id or query parameter.
404SUBSCRIPTION_NOT_FOUNDNo such subscription on your game. Another tenant's id returns this, never data.
429rate_limit_exceededPer-game and per-subscription limits both apply.
500INTERNAL_ERRORThe history could not be read.

4. Reconcile a single payout line

GET

/api/subscriptions/reporting/periods/{period_id}

One renewal in full. Answers, for exactly one cycle: what was billed, how it was funded, what Invo kept, what you received, what was attributed and to whom, whether the recorded attribution base agrees with the canonical revenue rows, every individual refund against it, and which charge attempt paid it. Takes no query parameters — period_id comes from a periods[].period_id on either endpoint above.

Response (200)

{
  "status": "success",
  "attribution_notice": { /* as in section 2 */ },
  "subscription": { /* as in section 3 */ },
  "period":       { /* the full renewal line from section 3 */ },
  "fee_legs": {
    "invo_fee_usd": "0.65",
    "invo_fee_percent": "6.50",
    "partner_revenue_usd": "6.53",
    "partner_revenue_settled_usd": "9.34",
    "partner_revenue_reversed_usd": "2.81",
    "partner_revenue_percent": "93.50",
    "partner_leg_reversed": true
    // plus "source" and "note", which spell out in words that these rows are
    // canonical and are never recomputed from a rate.
  },
  "refunds": {
    "refund_count": 1,
    "refunded_to_date_usd": "3.00",
    "latest_refund_at": "2026-08-14T16:05:02+00:00",
    "basis": "refund_receipts",
    "events": [
      {
        "refunded_at": "2026-08-14T16:05:02+00:00",
        "refunded_amount_usd": "3.00",
        "cumulative_refunded_before_usd": "0.00",
        "cumulative_refunded_after_usd": "3.00",
        "is_full_refund": false,
        "refund_transaction_id": "TXN_1786985511_B4W1",
        "reversed_attributed_usd": "0.28",
        "partner_revenue_reversed_usd": "2.81",
        "partner_revenue_reversal_basis": "clawback",
        "settled_by_invo": false
      }
    ]
  },
  "charge": {
    "attempts_for_period": 1,
    "paid_attempt_no": 1,
    "paid_attempt_at": "2026-07-01T09:02:09+00:00",
    "outcome": "paid"
  },
  "current_revenue_share": { /* as in section 3 */ }
}
FieldTypeNotes
fee_legsobjectThe canonical two-leg split for this renewal. There is no third leg for a recipient, and there never will be. Invo's fee has no reversed counterpart because it is retained on a refund.
refunds.refund_countintIndividual refund events against this renewal.
refunds.refunded_to_date_usd, latest_refund_atstring | nullCumulative total and the latest refund timestamp. Never prorate these into a window — that is exactly the double-count the per-event list exists to prevent.
refunds.basisstring"refund_receipts" or "period_row" — see limitation 1.
refunds.events[]arrayOne entry per refund, each with its own amount, its own cumulative before/after, and its own timestamp. This is what tells you which windowed report each reversal landed in.
events[].partner_revenue_reversal_basisstringclawback (a contra entry booked against the refund), sale_fee_row_reversed (the original revenue entry was reversed outright), or none.
chargeobjectattempts_for_period, paid_attempt_no, paid_attempt_at, outcome. Useful when a cycle took several attempts to clear.

Errors

Statuserror_codeCause
400INVALID_FIELD_VALUEAn unusable period_id.
404PERIOD_NOT_FOUNDNo such renewal on your game. Period ids are sequential and shared across tenants, so this is an authorisation answer as much as a lookup one.
429rate_limit_exceededCarries retry_after and a Retry-After header.
500INTERNAL_ERRORThe renewal could not be read.

5. Limits, caps and the v1 constraint

Limitation 1 — a refund whose reversal cannot be split per event

Normally every reversal is derived from the individual refund event it belongs to, so a renewal refunded in instalments across two windows reverses its August share in August and its September share in September. The pieces are monotone, never double-counted, and sum exactly to the whole reversal. Those lines carry reversal_basis: "refund_receipts".

When the per-refund record is unavailable for a renewal, the report falls back to the cumulative total on the renewal itself, and the whole reversal is reported in the window of the latest refund — coarser for an instalment refund spanning two windows. Those lines carry reversal_basis: "period_row", and totals.reversals_without_receipts counts them so you can check the number is zero before you pay. Any partner-revenue clawback on such a renewal is not visible to the report either.

Limitation 2 — a range that is too large is refused, not truncated

A window containing more than 20,000 settled renewals (or more than 20,000 refunded ones) returns 400 WINDOW_TOO_LARGE with the actual count in the body, asking you to narrow the range and run the report in parts. We would rather refuse than return a truncated total that looks complete — a partial number is how a recipient gets underpaid and nobody ever finds out.

v1 constraint: one recipient per subscription

A subscription attributes to exactly one recipient, enforced at the schema level, and that recipient must be a real player in your game. So a renewal line has at most one attribution object and at most one recipient_player_id. If a membership in your product could ever split across two recipients, tell us before you build — that is a schema change, and it is far cheaper to know now.

Every cap in one place

CapValueOn breach
Reporting window length400 days400 WINDOW_TOO_LONG
Settled renewals per report20,000400 WINDOW_TOO_LARGE
Refunded renewals per report20,000400 WINDOW_TOO_LARGE
Inline evidence lines per recipient500Flagged periods_truncated / reversals_truncated. Totals are never truncated.
limit100 (default 50)Silently clamped; not an error.
offset100,000400 PAGINATION_INVALID

Rate limits

The aggregate report is a range scan and is limited to 60 requests per minute per game by default. The two detail endpoints allow 600 per minute per game, with an additional 120 per minute per individual subscription. A 429 carries retry_after in the body and a Retry-After header — honour it rather than retrying immediately.

6. A monthly payout run, end to end

Run this on the 1st for the month that just closed. Because the window is keyed on settlement, it covers exactly the money that arrived.

curl — the July run, in your local month boundaries
# start is INCLUSIVE, end is EXCLUSIVE, and an explicit offset is honoured —
# so this is midnight-to-midnight Pacific, not UTC.
curl -G https://api.invo.network/api/subscriptions/reporting/attribution \
  -H "X-Game-Secret-Key: ivsdk_<your_sdk_key>" \
  --data-urlencode "start=2026-07-01T00:00:00-07:00" \
  --data-urlencode "end=2026-08-01T00:00:00-07:00" \
  --data-urlencode "limit=100"

# Reconcile one line back to the renewal behind it:
curl https://api.invo.network/api/subscriptions/reporting/periods/55129 \
  -H "X-Game-Secret-Key: ivsdk_<your_sdk_key>"

# One member's whole history, paid cycles only:
curl -G "https://api.invo.network/api/subscriptions/reporting/subscriptions/SUB_1786982400_A7K2M9QX/attribution" \
  -H "X-Game-Secret-Key: ivsdk_<your_sdk_key>" \
  --data-urlencode "status=paid,refunded"
Node.js — page the recipients and build your own payout batch
const BASE = 'https://api.invo.network/api/subscriptions/reporting';

async function monthlyPayoutRun(startIso, endIso) {
  const recipients = [];
  let offset = 0;
  let notice = null;
  let totals = null;

  for (;;) {
    const qs = new URLSearchParams({
      start: startIso,        // inclusive
      end:   endIso,          // exclusive
      limit: '100',
      offset: String(offset),
    });

    const res = await fetch(BASE + '/attribution?' + qs, {
      headers: { 'X-Game-Secret-Key': process.env.INVO_GAME_SECRET_KEY },
    });
    const body = await res.json();

    if (!res.ok) {
      // Refusal beats a truncated total that looks complete.
      if (body.error_code === 'WINDOW_TOO_LARGE') {
        throw new Error('Window has ' + body.settled_renewals +
                        ' settled renewals (max ' + body.max_renewals +
                        '). Split the month into halves and re-run.');
      }
      throw new Error(body.error_code + ': ' + body.message);
    }

    notice = body.attribution_notice;
    totals = body.totals;
    recipients.push(...body.recipients);

    if (!body.pagination.has_more) break;
    offset += body.pagination.limit;
  }

  // THIS IS A REPORT. Invo has paid nobody in it. Assert that rather than
  // assuming it, so the day it ever changes you find out from your own code.
  if (notice.settled_by_invo !== false || notice.payer !== 'partner') {
    throw new Error('Unexpected attribution notice — stop and read it.');
  }

  // Coarse-reversal lines are the one case where an instalment refund is
  // reported entirely in the later window. Check before you pay.
  if (totals.reversals_without_receipts > 0) {
    logger.warn(totals.reversals_without_receipts +
                ' line(s) used the coarse reversal path this window.');
  }

  // net_attributed_usd is the figure to pay. It is already net of every
  // reversal in the window, and a renewal that settled and was fully refunded
  // inside this window has already cancelled itself to zero.
  return recipients
    .filter(r => Number(r.net_attributed_usd) > 0)
    .map(r => ({
      playerId:   r.recipient.player_id,
      email:      r.recipient.player_email,   // null if resolved === false
      resolved:   r.recipient.resolved,       // investigate false before paying
      amountUsd:  r.net_attributed_usd,       // exact decimal string — do NOT
                                              // parse to a float to move money
      renewals:   r.renewal_count,
    }));
}

// You pay these out on your own rails, under your own agreements.
// Invo settles to you and only to you.

Related