From 43aa7490cd6344e030b58f034e1ba0c2787caf62 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sat, 24 Oct 2026 22:14:52 +0100 Subject: [PATCH 1/2] request the includable off-Stripe invoice amount --- crates/server/src/cloud_provider_stripe_http.rs | 7 ++++++- crates/server/tests/cloud_provider_stripe_http.rs | 9 +++++++++ crates/server/tests/stripe_resource_field_matrix.md | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/server/src/cloud_provider_stripe_http.rs b/crates/server/src/cloud_provider_stripe_http.rs index 2e977791..9724c999 100644 --- a/crates/server/src/cloud_provider_stripe_http.rs +++ b/crates/server/src/cloud_provider_stripe_http.rs @@ -413,7 +413,12 @@ impl StripeReadClient { self.ensure_account(session).await?; validate_identifier(invoice_id)?; let path = format!("v1/invoices/{invoice_id}"); - let value = self.request_json(session, Method::GET, &path, &[]).await?; + // Stripe marks amount_paid_off_stripe as includable rather than returning it by + // default. We require it to distinguish Stripe settlements from off-Stripe payment. + let query = [("expand[]".to_owned(), "amount_paid_off_stripe".to_owned())]; + let value = self + .request_json(session, Method::GET, &path, &query) + .await?; let resource = parse_invoice(&value)?; if resource.id != invoice_id { return Err(StripeReadError::ContextMismatch); diff --git a/crates/server/tests/cloud_provider_stripe_http.rs b/crates/server/tests/cloud_provider_stripe_http.rs index c0934ffe..aeda64e3 100644 --- a/crates/server/tests/cloud_provider_stripe_http.rs +++ b/crates/server/tests/cloud_provider_stripe_http.rs @@ -1676,6 +1676,15 @@ async fn collects_all_personal_invoices_under_one_session_and_keeps_non_paid_sta assert!(invoice_list_calls .iter() .any(|call| call.path_and_query.contains("starting_after=in_2"))); + let invoice_detail_calls: Vec<_> = calls + .iter() + .filter(|call| call.path_and_query.starts_with("/v1/invoices/in_1?")) + .collect(); + assert_eq!(invoice_detail_calls.len(), 1); + assert_eq!( + query_pairs(invoice_detail_calls[0]), + vec![("expand[]".to_owned(), "amount_paid_off_stripe".to_owned())] + ); } #[tokio::test] diff --git a/crates/server/tests/stripe_resource_field_matrix.md b/crates/server/tests/stripe_resource_field_matrix.md index f281bbae..fd24c8a8 100644 --- a/crates/server/tests/stripe_resource_field_matrix.md +++ b/crates/server/tests/stripe_resource_field_matrix.md @@ -26,7 +26,7 @@ at the repository's pinned version. The operation is evidence only and has no ru | Resource | Required current fields | Provenance and rejection rule | | --- | --- | --- | | `GET /v1/subscriptions/:id` | `id`, `customer`, `status`, `livemode`, `cancel_at_period_end`, `cancel_at`, `canceled_at`, `ended_at` | The ID, customer and mode must match the binding. Status must be recognised. The boolean and all three nullable cancellation fields must be present; JSON `null` is valid, omission and wrong type are malformed. Non-null timestamps must be nonnegative. A final-read presence change is reported with the provider field name. | -| `GET /v1/invoices/:id` | `id`, `customer`, `parent.type`, `parent.subscription_details.subscription`, `billing_reason`, `collection_method`, `status`, `currency`, all settlement amounts, `livemode`, `metadata.sotto_allocation_reference` | The current invoice must be an automatic `subscription_cycle` invoice in GBP with the nested subscription and trusted allocation claim. Missing or null billing fields are named shape errors; wrong values become `NeedsEvidence` with the field and value. The fields participate in final-read change diagnostics. | +| `GET /v1/invoices/:id` | `id`, `customer`, `parent.type`, `parent.subscription_details.subscription`, `billing_reason`, `collection_method`, `status`, `currency`, all settlement amounts (requesting includable `amount_paid_off_stripe` with `expand[]`), `livemode`, `metadata.sotto_allocation_reference` | The current invoice must be an automatic `subscription_cycle` invoice in GBP with the nested subscription and trusted allocation claim. Missing or null billing fields are named shape errors; wrong values become `NeedsEvidence` with the field and value. The fields participate in final-read change diagnostics. | | `GET /v1/invoices/:id/lines` | `id`, `invoice`, `quantity`, `livemode`, nested subscription and subscription item, nested `proration`, pricing type and configured price, `period.start`, `period.end` | Exactly one complete non-prorated subscription-item line with quantity one is supported. Optional legacy top-level `subscription` and `subscription_item` references may be present only when they agree with the nested references; they never fill missing nested fields. The validated line collection is reused for paid correction evidence. | Paid invoices require `amount_remaining=0`; open invoices require a positive due amount and @@ -44,7 +44,7 @@ does not publish coverage, assign a failed-renewal ID, or mutate storage. | Resource | Required fields used | Provenance and rejection rule | | --- | --- | --- | | `GET /v1/account` | `id` | Must equal the configured operator account before resource reads continue. Stripe's Account object does not include `livemode`; the client validates live/test mode from the API key prefix, then checks each resource's `livemode` field where available. | -| `GET /v1/invoices/:id` | `id`, `customer`, `parent.type`, `parent.subscription_details.subscription`, `status`, `currency`, `amount_paid`, `amount_due`, `amount_overpaid`, `amount_paid_off_stripe`, `livemode`, `metadata.sotto_allocation_reference` | The path ID, nested subscription parent, paid status, mode, currency, full positive amount and trusted allocation claim are checked. Missing financial, parent or metadata fields fail closed. The deprecated top-level `subscription` field cannot establish association. | +| `GET /v1/invoices/:id` | `id`, `customer`, `parent.type`, `parent.subscription_details.subscription`, `status`, `currency`, `amount_paid`, `amount_due`, `amount_overpaid`, `amount_paid_off_stripe` (requested with `expand[]`), `livemode`, `metadata.sotto_allocation_reference` | The path ID, nested subscription parent, paid status, mode, currency, full positive amount and trusted allocation claim are checked. Missing financial, parent or metadata fields fail closed. The deprecated top-level `subscription` field cannot establish association. | | `GET /v1/invoices/:id/lines` | `id`, `quantity`, `livemode`, `parent.type`, `parent.subscription_item_details.subscription`, `parent.subscription_item_details.subscription_item`, `pricing.type`, `pricing.price_details.price`, `period.start`, `period.end` | The complete paginated list must contain exactly one subscription-item line with quantity one and a configured standard price. Invoice-item and other line types are unsupported. | | `GET /v1/invoice_payments?invoice=:id` | `id`, `invoice`, `status`, `amount_paid`, `amount_requested`, `currency`, `livemode`, `payment.type`, `payment.payment_intent` | The complete list must contain exactly one paid PaymentIntent settlement. Open, canceled, multiple or mismatched records are ambiguous or unsupported. | From a596fd913ca70790144f4ede57a032f98066330d Mon Sep 17 00:00:00 2001 From: Maxerns Date: Sat, 24 Oct 2026 22:25:46 +0100 Subject: [PATCH 2/2] expect the invoice expansion in renewal reads --- crates/server/tests/cloud_provider_stripe_renewals.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/server/tests/cloud_provider_stripe_renewals.rs b/crates/server/tests/cloud_provider_stripe_renewals.rs index 57121e7e..1c0ebd7a 100644 --- a/crates/server/tests/cloud_provider_stripe_renewals.rs +++ b/crates/server/tests/cloud_provider_stripe_renewals.rs @@ -339,8 +339,8 @@ async fn sponsored_failure_read_ignores_a_late_event_for_a_closed_invoice() { server.requests.lock().unwrap().as_slice(), [ "GET /v1/account", - "GET /v1/invoices/in_sponsored_closed", - "GET /v1/invoices/in_sponsored_missing_status" + "GET /v1/invoices/in_sponsored_closed?expand%5B%5D=amount_paid_off_stripe", + "GET /v1/invoices/in_sponsored_missing_status?expand%5B%5D=amount_paid_off_stripe" ] ); } @@ -1540,13 +1540,13 @@ async fn current_paid_invoice_supersedes_historical_failure_and_preserves_cancel vec![ "GET /v1/account", "GET /v1/subscriptions/sub_renewal", - "GET /v1/invoices/in_failed", + "GET /v1/invoices/in_failed?expand%5B%5D=amount_paid_off_stripe", "GET /v1/invoices/in_failed/lines?limit=100", "GET /v1/invoice_payments?invoice=in_failed&limit=100", "GET /v1/refunds?payment_intent=pi_current&limit=100", "GET /v1/disputes?payment_intent=pi_current&limit=100", "GET /v1/credit_notes?invoice=in_failed&limit=100", - "GET /v1/invoices/in_failed", + "GET /v1/invoices/in_failed?expand%5B%5D=amount_paid_off_stripe", "GET /v1/subscriptions/sub_renewal", ] ); @@ -2517,7 +2517,7 @@ async fn open_to_paid_header_transition_returns_changed_fields_without_retrying( server .requests() .iter() - .filter(|request| request.as_str() == "GET /v1/invoices/in_failed") + .filter(|request| request.starts_with("GET /v1/invoices/in_failed?expand%5B%5D=")) .count(), 2 );