Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/server/src/cloud_provider_stripe_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions crates/server/tests/cloud_provider_stripe_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 5 additions & 5 deletions crates/server/tests/cloud_provider_stripe_renewals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
);
}
Expand Down Expand Up @@ -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",
]
);
Expand Down Expand Up @@ -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
);
Expand Down
4 changes: 2 additions & 2 deletions crates/server/tests/stripe_resource_field_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |

Expand Down
Loading