Skip to content

omnitrade/stripe

Omnitrade Stripe: cards and wallets through Checkout (the hosted page), refunds, webhook events and the catalogue of Stripe Products and Prices, on omnipay/stripe.

Floor
Provider · Omnitrade
Version
1.x
Conformance suite
runs, no CI read
Verified for real
Its README does not say
Licence
MIT
composer require omnitrade/stripe:1.x-dev

Stripe for omnisense/omnitrade: cards and wallets through Checkout (Stripe's hosted page), what was paid, refunds, the webhook's events, the catalogue kept in Stripe's Products and Prices, Connect (Express accounts and destination charges) and subscriptions (Checkout in subscription mode, the customer portal).

The payments go through Omnipay's omnipay/stripe (its Checkout and PaymentIntents gateways), on the application's HTTP client through a PSR-18 bridge; the rest are plain calls of Stripe's REST API on that same client. Everything is on https://api.stripe.com (Omnitrade\Stripe\Api::BASE_URI): Stripe has no sandbox address, a test key (sk_test_...) is test mode.

Operation Stripe API Path Through
purchase Checkout Sessions, mode=payment POST /v1/checkout/sessions omnipay
fetch Checkout Sessions GET /v1/checkout/sessions/{id} omnipay
refund the session, its payment intent, then the charge GET /v1/checkout/sessions/{id}, GET /v1/payment_intents/{id}, POST /v1/charges/{id}/refund omnipay
products Products, Products search, Prices GET /v1/products, GET /v1/products/search, GET /v1/products/{id}, GET /v1/prices REST
connected accounts Accounts, Account Links POST /v1/accounts, POST /v1/account_links, GET /v1/accounts/{id} REST
subscriptions Checkout Sessions, mode=subscription; Subscriptions; Billing portal POST /v1/checkout/sessions, GET/POST/DELETE /v1/subscriptions/{id}, POST /v1/billing_portal/sessions REST

Installation

composer require omnitrade/stripe

Requires PHP 8.2, ext-bcmath, omnisense/omnitrade (1.x), symfony/http-client (6.4, 7 or 8), omnipay/stripe (^3.2) and omnipay/common (^3.0), with nyholm/psr7, php-http/discovery, psr/http-client and psr/http-factory for the PSR-18 bridge. Omnipay requires symfony/http-foundation for its own request object: installed with it, loaded only when a payment is made, a library there with no kernel and no container. In a Symfony application with Omnitrade\Bridge\Symfony\OmnitradeBundle, the factory is registered as soon as the package is installed.

$gateway = (new StripeGatewayFactory($http))->create(['api_key' => '...', 'webhook_secret' => '...']);   // $http: the application's HTTP client; none given, the factory makes its own

Credentials

  • A secret key from the Stripe Dashboard (Developers → API keys): sk_test_... in test mode, sk_live_... live. A restricted key works for what it is allowed (the catalogue's reads need Products and Prices).
  • A webhook endpoint for the events below; its signing secret (whsec_...) is webhook_secret. Locally, stripe listen --forward-to <your webhook URL> prints one.
  • For Connect, Connect activated on the platform's account (Dashboard → Connect), with Express accounts allowed in the holders' countries; for the subscription portal, the customer portal set up once (Billing → Customer portal).

Options

Option Type Default Required
api_key string - yes the secret key
webhook_secret ?string null for notify() the endpoint's signing secret; without it every webhook is an InvalidNotificationException
adaptive_pricing bool false no true: Stripe may offer the buyer their own currency on Checkout. Always sent (adaptive_pricing[enabled]=false by default): the session is paid in the payment's currency
payment_methods list of strings [] no the Checkout methods to insist on (['card', 'sepa_debit'], payment_method_types), and what paymentMethods() lists; []: Stripe's choice
active_only bool false no true: fetchProducts() lists the active products only (catalogue)
omnitrade:
    gateways:
        card:
            factory: stripe
            options:
                api_key: '%env(STRIPE_API_KEY)%'               # sk_live_... / sk_test_...
                webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%' # whsec_..., for notify()
                adaptive_pricing: false                        # true: the buyer's own currency may be offered
                payment_methods: []                            # ['card', 'sepa_debit']: the methods to insist on

What a Payment is sent as: reference is client_reference_id; customer->email customer_email; the lines (when they and shipping add up to the amount, with no discount) line_items with their name, image, unit amount and quantity, and shipping the session's one shipping rate (shipping_options[0][shipping_rate_data], a fixed_amount named "Shipping"), else one line of the amount named by description (else reference); returnUrl the success_url, with session={CHECKOUT_SESSION_ID} added to its query; cancelUrl the cancel_url (else returnUrl with cancel=1); idempotencyKey the Idempotency-Key header; method payment_method_types (one method, over the payment_methods option); notice custom_text[submit][message] and the payment intent's description; metadata, as strings; locale its first two letters; destination and applicationFee a destination charge (Connect). Not sent: the customer's other fields, a line's sku, reference and physical, discount as such (the amount is one line then, below).

Capabilities

Request Supported
Purchase yes (a Checkout session)
Authorize, Capture, VoidAuthorization no: Checkout takes the money when the buyer pays
Refund yes
FetchTransaction yes (the session)
GetPaymentMethods yes (a list from the options)
Notify yes
FetchOrder no
FetchProducts, FetchProduct yes (Products and Prices)
FetchInventory no: Stripe counts no stock
AffiliateLink no
CreateAccount, AccountLink, FetchAccount yes (Connect)
Subscribe, FetchSubscription, CancelSubscription, SubscriptionPortal yes

Behaviour

Purchase. A Checkout session in payment mode, answered PENDING with the session's url as redirectUrl. A payment without returnUrl is a ProviderException before any call (A Checkout session needs a returnUrl: where Stripe sends the buyer back.); an answer without an id or a url is one too (Stripe opened no Checkout session., or Stripe's error message). The session always charges the payment's amount. The lines are listed when Payment::linesAddUp() and there is no discount, the shipping on top of them as the session's shipping rate (two lines of 12.00 EUR and 3.50 EUR of shipping: 27.50 EUR). With a discount, the amount is one line: Checkout's line items take no negative amount, and its discounts take only a coupon or a promotion code made beforehand (Create a Checkout Session).

Fetch. The session as Stripe has it: payment_status paid or no_payment_required is PAID, status expired EXPIRED, anything else PENDING with the session's url. The transaction's message is the session's status, method its first payment_method_types, metadata its metadata, createdAt its created. The fetch asks Stripe to expand the session's payment intent and its latest charge (expand[]=payment_intent.latest_charge): a refund is read from the charge's amount_refunded (or the intent's, when given), as REFUNDED, or PARTIALLY_REFUNDED when less than the session's amount_total, with refunded.

Refund. The reference is the session's id (cs_...), a payment intent's (pi_...) or a charge's (ch_...). From a session, the session is read for its payment intent, the intent for its latest_charge (else its first charge), and the charge is refunded: with an amount through omnipay (POST /v1/charges/{charge}/refund, the amount in minor units), without one in full (POST /v1/refunds with the charge alone: Stripe refunds what is left of it, Create a refund); the refund's Idempotency-Key is the request's key. Stripe's succeeded is REFUNDED, any other status PENDING; failed and canceled are ProviderExceptions. The reason is not sent. The currency is the payment's (EUR for a bare ch_... without an amount).

Payment methods. No call: the payment_methods option, else card, apple_pay, google_pay, link. Labels and kinds come from a table of eleven codes (card; apple_pay, google_pay, link, paypal wallets; sepa_debit, bancontact, ideal, eps banks; klarna, afterpay_clearpay buy now pay later); another code is labelled from its name, kind other. Given an amount, its currency narrows the list: sepa_debit, bancontact, ideal and eps take euros only and are left out of an amount in another currency (payment method support); the country is not read, Stripe choosing on its page by the buyer's.

Notify. Stripe-Signature is checked first: t=<timestamp>,v1=<signature>, the HMAC-SHA256 of <timestamp>.<body> with webhook_secret, any v1 matching, the timestamp within 300 seconds. Then the event:

Event status Carries
checkout.session.completed, checkout.session.async_payment_succeeded PAID when the session's payment_status is paid, else PENDING transaction (the session)
checkout.session.expired EXPIRED transaction
checkout.session.async_payment_failed REFUSED transaction
account.updated - account
customer.subscription.created, .updated, .deleted - subscription
product.created, product.updated - product, with its active prices (one call)
price.created, price.updated, price.deleted - product: the price's product read afresh (two calls); reference the product's id
product.deleted - reference the product's id, no product
any other (invoice.paid, invoice.payment_failed...) - raw

reference is the event's object's id; id the event's. A body that is not a Stripe event is a ProviderException. Subscribe the endpoint to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.expired, checkout.session.async_payment_failed, and to the catalogue's, Connect's and subscriptions' events when used.

Calls. omnipay sends the secret key as HTTP basic authentication; the REST calls as a bearer token, form-encoded (a[b][c]=..., booleans true/false, null values left out), with a 30-second timeout. No token, no state kept between calls.

The catalogue, Connect and subscriptions: Stripe's catalogue, Connect and subscriptions.

Example

<?php

require __DIR__.'/vendor/autoload.php';

use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\Stripe\StripeGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

$stripe = (new StripeGatewayFactory(HttpClient::create()))->create([
    'api_key' => getenv('STRIPE_API_KEY'),                 // sk_test_...
    'webhook_secret' => getenv('STRIPE_WEBHOOK_SECRET'),
]);

$transaction = $stripe->purchase(new Payment(
    amount: Money::of(2750, 'EUR'),
    reference: 'ORDER-1042',
    customer: new Customer(email: 'camille@example.org'),
    lines: [new Line('Margaux 2019', Money::of(1200, 'EUR'), 2), new Line('Colissimo', Money::of(350, 'EUR'))],
    returnUrl: 'https://shop.example/paid',                 // Stripe comes back with ?session=cs_...
    idempotencyKey: 'ORDER-1042-attempt-1',
    locale: 'fr',
));
header('Location: '.$transaction->redirectUrl);

// on https://shop.example/paid?session=cs_...
$paid = $stripe->fetch($_GET['session'])->isPaid();

// later, part of it back
$stripe->refund($transaction->reference, Money::of(500, 'EUR'), idempotencyKey: 'ORDER-1042-refund-1');

The same flow on recorded answers, offline, with its output, is in the core's manual (payments). The core's Docker harness calls the gateway with the keys in docker/.env: docker compose run --rm omnitrade purchase stripe --amount 1990.

Errors

Every failure is an Omnitrade\Exception\ProviderException whose provider is stripe:

  • an HTTP status of 400 or more on a REST call: Stripe's error.message, its error.code in providerCode (resource_missing);
  • a body that is not JSON: Stripe answered HTTP <status> with a body that is not JSON.;
  • a network failure, on a REST call or through omnipay: Stripe request failed: ..., a copy of the HTTP client's exception as previous;
  • omnipay's answers without what is needed: Stripe opened no Checkout session., Stripe knows no such session., Stripe refused the refund., Stripe knows no payment for this session., Stripe knows no charge for this payment.;
  • a webhook: InvalidNotificationException for a missing secret, an unsigned or too old event (The notification is unsigned, or too old.) or a signature that does not match.

A missing api_key is an InvalidConfigException when the gateway is built; Authorize, Capture, VoidAuthorization, FetchOrder, FetchInventory and AffiliateLink raise RequestNotSupportedException before any call. fetchProduct() of an unknown id (Stripe's resource_missing), or of an address, is null. A request omnipay refuses before sending it is a ProviderException too (Stripe request not sent: ...).

No failure carries the secret key: a network failure's message has them masked as *** (the key parameters of a URL too), its previous is a copy of the HTTP client's exception (the same class and code, without its request), and no trace of the chain keeps its arguments, where the request's options stood (No secret in a failure).

Verified, and not

Tested offline, 38 tests on answers shaped as Stripe's API reference gives them: Tests/StripeGatewayTest.php (a Checkout session opened, a session fetched, a refund on its charge with its idempotency key, a refund without an amount in full, a refunded session fetched REFUNDED, the shipping as Checkout's shipping rate and a discount charged as one line, a webhook checked then read, the payment methods), Tests/StripeConnectTest.php (an Express account opened then completed on Stripe's page, a payment sent to a connected account with the platform's fee, the fee's checks, Stripe's errors, a subscription started on Checkout, read, stopped and managed, the account and subscription events), Tests/StripeCatalogueTest.php (no stock, pages, updatedSince among each page, search, a variant per price, products without prices and recurring prices, addresses and unknown ids, product and price events) and Tests/StripeContractTest.php, the family's conformance suite. The suite found omnipay's network failures escaping as omnipay's exception; they now go through Api::sendOmnipay().

Not verified: no call was made to Stripe, in test mode or live; no recorded answer was captured from a real account (the shipping rate, the expanded charge and the full refund follow Stripe's API reference).

Responsibility

An independent, unofficial client, not affiliated with, endorsed by or sponsored by Stripe, Inc. Whoever runs it complies with Stripe's terms (Stripe Services Agreement) and obtains what they require: see the README's Responsibility section.

Propose a change docs/index.md

Catalogue

The products kept in Stripe (Products and their Prices), read through the omnitrade catalogue contract. Stripe counts no stock: supports(FetchInventory::class) is false, fetchInventory() throws RequestNotSupportedException, and the stock stays the site's.

Setting it up

composer require omnitrade/stripe
omnitrade:
    gateways:
        card:
            factory: stripe
            options:
                api_key: '%env(STRIPE_API_KEY)%'               # a secret or restricted key reading Products and Prices
                webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%' # for the product and price events
                active_only: false
Option Default
active_only false true: fetchProducts() lists the active products only (active=true, or active:"true" in a search). Otherwise the archived ones come too, as Product::ARCHIVED.

The catalogue's reads are plain GETs on https://api.stripe.com through the HTTP client the factory is given (Authorization: Bearer <api_key>); the payments still go through omnipay.

Requests

Request What is sent
fetchProducts(?cursor, ?updatedSince, ?query, limit) GET /v1/products?limit=limit (≤ 100)&starting_after=cursor&expand[]=data.default_price; the next cursor is the page's last product id while has_more. With a query: GET /v1/products/search?query=name~"<query>" (name:"<query>" under three characters, Stripe's minimum for a substring), the cursor being next_page. Then, for each product, GET /v1/prices?product=<id>&active=true&limit=100.
fetchProduct($reference) GET /v1/products/<prod_…>?expand[]=default_price, then its prices. An address answers null (Stripe hosts no product pages), as an unknown id does.
fetchInventory() not supported

updatedSince: Stripe's search does not know a product's update date (its product fields are active, description, metadata, name, shippable, url), so the products of each page whose updated is earlier are left out. A page may then come back short, or empty with a next cursor: keep paging while hasMore(). A new or changed price does not change its product's updated: the price.* webhooks are what tell of those. Pass the same updatedSince and query with the cursor.

Search is eventually consistent (a minute or so), limited to 20 requests a second, and not offered to accounts in India.

What is mapped how

Stripe omnitrade
id (prod_…) Product::$reference
name title
description (plain text) description, made HTML (escaped, line breaks as <br>)
metadata attributes, all of it
metadata.brand brand
metadata.categories (or category), metadata.tags categories, tags (comma-separated)
metadata.slug (or handle) handle
images[] media (images)
url url, and each offer's
active Product::ACTIVE, else ARCHIVED
updated updatedAt
package_dimensions.weight (ounces) each variant's weight, in grams
each active price, default_price first a variant: reference the price's id; title its nickname, else its metadata title/name/variant, else (several prices) "24.00 EUR / month"; an only price has no title
price unit_amount (or unit_amount_decimal), currency Offer price, with reference the price id; prices chosen by the buyer or tiered are skipped
price tax_behavior taxIncluded: inclusive true, exclusive false, else null
price metadata the variant's attributes (its sku the variant's SKU; an only price takes the product's metadata.sku)
price recurring the variant's attributes.recurring (month, 3 month)
no price at all one variant, the product's id, no offer

No stock on any variant (ProductVariant::$stock is null), no options.

Webhooks

Add product.created, product.updated, product.deleted, price.created, price.updated, price.deleted to the webhook endpoint; notify() checks Stripe-Signature as for Checkout.

Event Notification
product.created, product.updated $product: the event's product with its active prices (one call).
price.created, price.updated, price.deleted $product: the price's product, read afresh with its prices (two calls); $reference the product's id.
product.deleted $product null, $reference the product's id.

Examples

$page = $stripe->fetchProducts(updatedSince: $lastSync);
while (true) {
    foreach ($page->products as $product) {
        // $product->brand, $product->variants[0]->offer()->reference (the price to sell at)
    }
    if (!$page->hasMore()) {
        break;
    }
    $page = $stripe->fetchProducts($page->next, updatedSince: $lastSync);
}

$product = $stripe->fetchProduct('prod_Q1x');
$stripe->supports(\Omnitrade\Request\FetchInventory::class);   // false

With the harness of omnisense/omnitrade (core/docker):

docker compose run --rm omnitrade catalogue stripe --query=margaux
docker compose run --rm omnitrade catalogue stripe --product=prod_Q1x

Propose a change docs/catalogue.md

Connect and subscriptions

The contract is omnisense/omnitrade's (connected accounts, subscriptions); this is what Stripe is asked.

Connect

The platform's own secret key does everything: no Stripe-Account header, no OAuth. Connect must be activated on the platform's Stripe account (dashboard → Connect), with Express accounts allowed in the holders' countries.

Request Call Notes
CreateAccount POST /v1/accounts type=express, country, email, business_type, capabilities[<name>][requested]=true (transfers by default), metadata; the request's idempotency key as Idempotency-Key
AccountLink POST /v1/account_links type=account_onboarding (or account_update), return_url, refresh_url
FetchAccount GET /v1/accounts/{id} charges_enabled, payouts_enabled, details_submitted, requirements.currently_due + past_due

A Payment with a destination makes the Checkout session a destination charge: payment_intent_data[transfer_data][destination] and, with an applicationFee, payment_intent_data[application_fee_amount]. The charge is the platform's (its name on the statement, its Stripe fees, its disputes); the amount less the fee is transferred to the connected account at once. A refund through refund() refunds the charge; it does not reverse the transfer nor give the fee back (do those in Stripe's dashboard when needed).

In some countries a destination charge needs the card_payments capability with transfers: pass capabilities: ['transfers', 'card_payments'].

Subscriptions

Request Call
Subscribe POST /v1/checkout/sessions, mode=subscription; one line: the request's price, or price_data (the payment's amount, recurring[interval], [interval_count], the description as the product's name); subscription_data[metadata], [trial_period_days]; customer or customer_email
FetchSubscription GET /v1/subscriptions/{id}
CancelSubscription POST /v1/subscriptions/{id} with cancel_at_period_end=true, or DELETE to stop at once
SubscriptionPortal POST /v1/billing_portal/sessions (customer, return_url, locale): the portal must be set up once in the dashboard (Billing → Customer portal)

The period's dates are read from the subscription, or from its first item (where Stripe's newer API versions keep them).

What is mapped how

Model\Account, from Stripe's account:

Stripe omnitrade
id (acct_...) reference
type type (express when absent)
country, email country, email
details_submitted, charges_enabled, payouts_enabled detailsSubmitted, chargesEnabled, payoutsEnabled (isReady(): both enabled)
requirements.currently_due + requirements.past_due requirements, once each
default_currency defaultCurrency, upper-cased
metadata metadata

Model\Subscription, from Stripe's subscription:

Stripe omnitrade
id (sub_...) reference
status active, trialing, past_due, unpaid, paused the same constant
status canceled, incomplete_expired Subscription::CANCELLED
any other status (incomplete) Subscription::INCOMPLETE
customer (an id, or an expanded customer's id) customer
the first item's price unit_amount times its quantity, its currency amount
the first item's price recurring.interval, recurring.interval_count interval, intervalCount
current_period_start, current_period_end (else the first item's) currentPeriodStart, currentPeriodEnd
cancel_at_period_end, canceled_at cancelAtPeriodEnd, cancelledAt
the first item's price id price
metadata metadata

Subscribe also sends client_reference_id (the payment's reference), locale (its first two letters), the payment's metadata on the session and in subscription_data[metadata], the payment's description as subscription_data[description], and the request's customer (then no customer_email) or the payment's customer's email. A payment without returnUrl is a ProviderException before any call; the success and cancel addresses are made as for a purchase (?session={CHECKOUT_SESSION_ID}, ?cancel=1). The session is answered PENDING with its url. The payment's lines, method, notice, destination and Stripe's payment_methods and adaptive_pricing options are not sent for a subscription.

Events

Forward these to the gateway's webhook, the Connect ones included (stripe listen --forward-to … --forward-connect-to …):

Event Notification
account.updated $account, $reference = acct_…
checkout.session.completed of a subscription $transaction PAID; raw['subscription'], raw['customer']
customer.subscription.created / .updated / .deleted $subscription, $reference = sub_…
invoice.paid, invoice.payment_failed no status: read $raw (the subscription's own events say what changed)

Not verified against Stripe's live API here: the package's tests run on recorded answers (Tests/StripeConnectTest.php).

Propose a change docs/connect.md