Search by

A small Stripe client for Connect platforms: pinned API version, Stripe-Account per request, a fetch that throws instead of answering null, signed event parsing with payload redaction, and readers for the fields Stripe moves between API versions.

Package info

github.com/SugarCommerce/stripe

Homepage

Issues

pkg:composer/sugarcommerce/stripe

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

1.5.0 2026-10-22 10:05 UTC

This package is auto-updated.

Last update: 2026-10-22 15:44:46 UTC


README

A small Stripe client for Connect platforms: pinned API version, Stripe-Account per request, a fetch that throws instead of answering null, signed event parsing with payload redaction, and readers for the fields Stripe moves between API versions.

Why

A platform that calls a handful of Stripe endpoints does not need the official SDK, but the few things it does need are each easy to get quietly wrong:

  • "No answer" read as "an empty answer". A webhook that treats a failed line-item read as a session with nothing in it finishes a paid order empty and acknowledges the event, so Stripe never retries.
  • Fields that move. When the pinned version passed 2025-03-31.basil, current_period_end left the subscription for its items and read as null. Nothing failed; grace windows and lapse sweeps just switched off.
  • The envelope is forgotten. account is the only thing telling a connected account's event from the platform's own, and created is the only defence against Stripe's out-of-order delivery.
  • Payloads kept verbatim. Stored events are useful for replay and carry names, addresses, phone numbers and tax ids nobody needed.
  • The first page read as the whole list. One GET prices?limit=100 was a shop's whole catalogue, and the hundred-and-first price silently vanished.
  • One key for two modes. A live key cannot read a test checkout; a platform with one key fails every call for the other mode, and an account installed in a Stripe sandbox needs the sandbox's key.
  • Stripe's reason thrown away. post() answering null is right until a buyer is looking at the result and Stripe said exactly which item it would not take.
  • One install, two events. Since 2026-09-30.endive Stripe announces an app install as account.application.authorized and apps.install.created (app.install.* on older endpoints) โ€” two ids, so event-id idempotency lets both through, and a handler that treats each as news records the install twice.
  • A version split that removes a value. Endive reports a platform's rejection as rejected.platform_fraud, not rejected.fraud; a list of rejection reasons written before it stops matching without an error.
  • Half of Managed Payments. managed_payments[enabled] makes Stripe the merchant of record, and a request that still sends automatic_tax, tax_id_collection, invoice_creation, customer_update[address] or a dozen others is refused whole โ€” and the lists differ between Checkout Sessions and Payment Links.
  • Cart rules learned from Stripe's error. One currency, one renewal schedule, item limits, pay-what-you-want alone โ€” a buyer should read that before a session is created, not after.

Features

  • ๐Ÿ“Œ Pinned โ€” every request sends Stripe-Version: Client::API_VERSION, currently 2026-09-30.endive. Create your webhook endpoints at the same version: an event's shape, and for app installs its name, follow the endpoint's version, not the request's.
  • ๐Ÿข Connect-first โ€” pass an account id and the request is made as that account.
  • ๐Ÿ’ฅ fetch() and attempt() throw โ€” StripeError with the path, status and Stripe's parsed error (code(), param(), type(), decline_code(), message()); get()/post() answer null where a fallback is fine.
  • ๐Ÿ“š each() / fetch_all() โ€” every object in a list, following has_more with starting_after; a failed page throws rather than answering half a list.
  • ๐Ÿงพ charge_for_intent(), receipt_url(), portal_url() โ€” one charge read for the receipt and the billing details; a billing portal session on request.
  • ๐Ÿ”‘ Keyring โ€” a client per mode (and the managed sandbox's), picked per call with Keyring::pick(); Keyring::key_mode() reads a key's mode from its prefix. Keyless refuses every call without making it, saying which key is missing.
  • โœ๏ธ Event::receive() / receive_any() โ€” verifies Stripe-Signature (HMAC + replay window, via sugarcommerce/webhooks) against one secret or several, says which one signed it, and unpacks id, type, account, mode, created and object.
  • ๐Ÿ™ˆ Event::redact() โ€” strips name, phone, address and tax ids; keeps email.
  • ๐Ÿงญ Shape โ€” reads for fields that move or are easy to misread: period_end(), invoice_subscription(), invoice_line_price(), invoice_line_product(), invoice_payment_intents(), subscription_price(), session_email(), session_paid(), custom_field(), promotions_consent(), fully_refunded(), dispute_is_inquiry(), account_disabled_reason(), account_rejected() (Stripe's rejections and, since endive, a platform's: Shape::PLATFORM_REJECTED), entitlement_feature(), id(), and a flat price() (with tiers(), currencies(), amounts(), custom_amount()).
  • ๐Ÿ›’ Cart โ€” Stripe Checkout's item rules as codes, an application's own rule beside them, optional extras that Stripe will accept, line_items() and the customer_params() each mode takes.
  • ๐Ÿšซ Refusal::classify() โ€” what Stripe refused, as a reason and a status an application can put into words.
  • ๐Ÿงพ ManagedPayments โ€” Stripe as merchant of record: session() and link() add managed_payments[enabled] and take out every parameter Stripe refuses beside it, eligible() checks a product's tax code against ELIGIBLE_TAX_CODES, and unsupported() names any refused parameter a request still carries.
  • ๐Ÿ“ฒ AppInstall::of() โ€” Installed, Uninstalled or null for any event, reading account.application.*, apps.install.* and the pre-endive app.install.* alike, and only an Install whose status is installed as an install; AppInstall::TYPES lists them for a dispatch.
  • ๐Ÿ” SubscriptionStatus โ€” Stripe's eight statuses sorted into running, paid, lapsed and ended, with access_until().
  • ๐Ÿ” RequestDigest and AppSignature โ€” verify a Stripe App's signed request, bound to its route and body, with the digest @arraypress/stripe-app builds in the browser.

Requirements

PHP 8.5+, sugarcommerce/http-client, sugarcommerce/webhooks

Installation

composer require sugarcommerce/stripe

Calling the API

use SugarCommerce\Stripe\{Client, StripeError};

$stripe = new Client( $secret_key );

$prices = $stripe->get( 'prices', 'acct_123', array( 'active' => 'true', 'expand' => array( 'data.product' ) ) );
$link   = $stripe->post( 'payment_links', array( 'line_items' => array( array( 'price' => 'price_1', 'quantity' => 1 ) ) ), 'acct_123' );

try {
    $items = $stripe->session_line_items( 'cs_123', 'acct_123' );
} catch ( StripeError $e ) {
    http_response_code( 500 ); // Stripe will retry
}

// Every page of a list, not the first hundred.
$all = $stripe->fetch_all( 'prices', 'acct_123', array( 'active' => 'true', 'expand' => array( 'data.product' ) ) );

// A write where Stripe's reason matters.
try {
    $session = $stripe->attempt( 'checkout/sessions', $params, 'acct_123' );
} catch ( StripeError $e ) {
    $e->code();   // 'resource_missing'
    $e->param();  // 'line_items[0][price]'
}

A path is checked before anything is sent: every segment must be a plain name or id (letters, digits, _, -), so 'prices/' . $id built from a buyer's input cannot become prices/../customers/โ€ฆ or carry its own ?query. An account must look like acct_โ€ฆ. Either refusal fails the way an outage does โ€” null from get()/post(), a StripeError from fetch()/attempt() โ€” and a dumped Client or Keyring shows no keys.

One key per mode

use SugarCommerce\Stripe\Keyring;

$stripe = Keyring::from_keys(
    live: $live_key,
    test: $test_key,
    sandbox: $sandbox_key,
    sandboxed: fn ( string $account ): bool => $installs->is_sandbox( $account ),
);

Keyring::pick( $stripe, $event->mode(), $event->account )->session_line_items( $id, $event->account );
Keyring::key_mode( 'rk_live_โ€ฆ' ); // 'live'

A mode with no key is a Keyless: reads that may fail answer null, reads that must succeed throw, and nothing is sent to Stripe with an empty bearer.

Checkout carts

use SugarCommerce\Stripe\{Cart, Refusal, Shape};

$cart    = new Cart( fn ( array $price ): ?string => 0 === $price['amount'] ? 'free' : null );
$checked = $cart->check( array_map( Shape::price( ... ), $prices ), 'gbp' );

if ( ! $checked['ok'] ) {
    echo $words[ $checked['code'] ]; // 'currency', 'interval', 'too_many', 'custom_alone', 'metered', 'free'โ€ฆ
}

$params = Cart::line_items( $checked['prices'] ) + Cart::customer_params( $email, 'subscription' === $checked['mode'] );

try {
    $session = $stripe->attempt( 'checkout/sessions', $params, $account );
} catch ( StripeError $e ) {
    [ 'reason' => $reason, 'status' => $status ] = Refusal::classify( $e ); // 'tax_not_ready', 503
}

Managed Payments

use SugarCommerce\Stripe\{ManagedPayments, Refusal};

// The same parameters you would send anyway; the refused ones come out.
$params = ManagedPayments::session( $params ); // + managed_payments[enabled]=true
$link   = ManagedPayments::link( $link_params ); // its own list: custom_text too

ManagedPayments::eligible( $product['tax_code'] );          // txcd_10401100 โ†’ true
ManagedPayments::unsupported( $params, ManagedPayments::SESSION_UNSUPPORTED ); // [] โ€” assert it in a test

Refusal::classify( $e )['reason']; // 'tax_code' (a product without an eligible code), 'managed_payments' (not enabled, terms, account)

The account turns it on in its Dashboard (Settings โ†’ Managed Payments) and accepts the terms; each product needs a tax code from ELIGIBLE_TAX_CODES (digital goods and services). It is for direct integrations: Stripe does not support it for Connect platforms โ€” application fees, transfers, on_behalf_of โ€” or accounts a platform controls, so neither list has a way to add those back. Promotion codes, custom fields, consent collection and the recovery of an expired session are not on Stripe's lists and pass through. The lists are Stripe's as published on 7 October 2026; check update-checkout and use-payment-links when bumping the API version.

Subscription standing

use SugarCommerce\Stripe\SubscriptionStatus;

$status = SubscriptionStatus::of( $subscription['status'] );
$status?->lapsed();                                   // unpaid, paused
$status?->access_until( Shape::period_end( $subscription ) );
SubscriptionStatus::ENDED;                            // for SQL: canceled, incomplete_expired

Stripe App requests

use SugarCommerce\Stripe\{AppSignature, RequestDigest};

$digest = RequestDigest::of_request( $method, $path, $query, $body );
$proof  = AppSignature::proof( $signature_header, $app_secret, $user_id, $account_id, array( 'request' => $digest ) );
// '' when it does not verify; otherwise "{timestamp}.{v1}", stable enough to spend once.

The browser half is @arraypress/stripe-app; both are tested against tests/fixtures/request-digest.json.

Receiving webhooks

use SugarCommerce\Stripe\{AppInstall, Event, InvalidEvent};

try {
    $event = Event::receive( $body, $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '', $webhook_secret );
} catch ( InvalidEvent $e ) {
    http_response_code( 400 );
    exit( $e->getMessage() );
}

$event->platform();   // true when it happened in your own account
$event->account;      // otherwise, the connected account
$event->mode();       // 'live' | 'test'
$event->created;      // compare before applying an update
$event->object;       // data.object

$store( Event::redact( $body ) );

// An install or an uninstall, whichever family of event said so. Both arrive for one install:
// compare $event->created with the last change you recorded rather than acting on each.
match ( AppInstall::of( $event ) ) {
    AppInstall::Installed   => $installs->open( $event->account, $event->mode(), $event->created ),
    AppInstall::Uninstalled => $installs->close( $event->account, $event->mode(), $event->created ),
    null                    => null, // not an install event, or one still in progress
};

// One URL registered as several endpoints: which secret verified it says where it came from.
[ $event, $from ] = Event::receive_any( $body, $header, array( 'platform' => $ours, 'sandbox' => $sandbox_secret ) );

A verified event is genuine, not new: Stripe delivers some events more than once, and a captured request replays within the five-minute window. Record $event->id and do each event's work once.

Testing

composer test

194 tests: the client (pinning to endive, Connect headers, structured errors, attempt(), paging and its failure and runaway cases, charges, the portal), events (verification, several secrets, redaction), every Shape read in its current and pre-basil location (and endive's platform rejections), both families of app install event and the Install lifecycle, a failed tax calculation sorted as Stripe's failure, the keyring and keyless refusals, every subscription status, the cart rules and extras, refusal classification, the shared request-digest vectors and app signatures, and that every must-check result keeps #[\NoDiscard].

In your own tests, pass SugarCommerce\HttpClient\Fake as the client's HTTP client:

$http   = ( new Fake() )->on( 'GET', 'https://api.stripe.com/v1/charges*', new Response( true, 200, '{"data":[]}' ) );
$stripe = new Client( 'sk_test_x', $http );

License

GPL-2.0-or-later