NovinkaZverejnili sme 50+ Laravel balíkov ako open source
Custom AI apps, agents and automation — Roundly ConsultingRoundly
Všetky balíky
Purchases for Laravel

Fakturácia cez Stripe

Purchases::stripe() vytvára a mení veci v Stripe cez jeho REST API a HTTP klienta Laravelu — bez Stripe SDK. Prichádzajúce webhooky udržiavajú odchádzajúci stav lokálne synchronizovaný. Fakturovať vie len Stripe; Apple a Google predávajú vo vlastných obchodoch. Fakturácia vyžaduje PURCHASES_STRIPE_SECRET a každé volanie používa pripnutú verziu API:

use RoundlyConsulting\Purchases\Facades\Purchases;

Purchases::canBill('stripe');   // true — the provider implements Contracts\SupportsBilling
Purchases::canBill('apple');    // false
Purchases::stripe();            // StripeBilling; UnsupportedCapabilityException without a registered Stripe provider

// Dependency injection — the same object behind the facade
public function __construct(private PurchasesManager $purchases) {}
$this->purchases->stripe()->subscriptions()->updateQuantity($seatItem, 11);
Sub-accessorMetódaAkcia
customers()sync($owner, CustomerData $data) / id($owner) / owner($customerId)SyncStripeCustomerAction
subscriptions()create($owner, CreateSubscriptionData $data)CreateStripeSubscriptionAction
subscriptions()checkout($owner, CheckoutSubscriptionData $data)CreateStripeCheckoutSessionAction
subscriptions()updateQuantity() / swap() / addItem() / removeItem() / change()ChangeStripeSubscriptionAction
subscriptions()cancel() / cancelAtPeriodEnd()CancelStripeSubscriptionAction
subscriptions()resume()ResumeStripeSubscriptionAction
subscriptions()preview($subscription, ?SubscriptionChange $change)PreviewStripeInvoiceAction
meters()create() / find() / findOrCreate()CreateStripeMeterAction / FindStripeMeterAction
meters()report() / queue()ReportStripeMeterEventAction / ReportStripeMeterEvent job
—portal($owner, ?$returnUrl, ?$configuration)CreateStripePortalSessionAction

Predplatné je uložené predplatné Stripe alebo jeho id (sub_…), položka uložený SubscriptionItem alebo jeho id (si_…); uložené predplatné iného poskytovateľa sa odmietne cez BillingException skôr, než sa čokoľvek odošle. Každý zápis posiela Idempotency-Key — váš alebo nové UUID —, ktorý nesie každá StripeException, takže neúspešné volanie zopakujete s tým istým kľúčom.

Zákazníci — jeden na vlastníka

use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\{AddressData, CustomerData, TaxIdData};

$customer = Purchases::stripe()->customers()->sync($company, new CustomerData(
    name: 'Acme s.r.o.',
    email: '[email protected]',
    address: new AddressData(line1: 'Hlavná 1', city: 'Bratislava', postalCode: '81101', country: 'SK'),
    taxIds: [new TaxIdData('eu_vat', 'SK2020123456')],
    metadata: ['plan' => 'team'],
    preferredLocale: 'sk',
));

Purchases::stripe()->customers()->id($company);        // 'cus_…' or null
Purchases::stripe()->customers()->owner('cus_…');      // the owner model or null

Vlastníkom je ľubovoľný uložený Eloquent model. Bez zákazníka ho sync() vytvorí a prepojí; s existujúcim aktualizuje len polia, ktoré nastavíte, a zoznam taxIds sa stane presne týmto zoznamom. Prepojenie žije v billing_customers cez kontrakt BillingCustomers — ak id už ukladáte inde, naviažte vlastnú implementáciu. Keď sa uloží predplatné alebo platba, ktorá ešte nemá vlastníka, prepojí sa s vlastníkom svojho zákazníka.

Predplatné

use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\{CreateSubscriptionData, SubscriptionPrice};
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\{CollectionMethod, PaymentBehavior};

$subscription = Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
    prices: [
        SubscriptionPrice::licensed('price_seats', 10),   // per seat, quantity 10
        SubscriptionPrice::metered('price_storage_gb'),   // usage-based: no quantity
    ],
    name: 'team',                                         // the local row's name, what subscribedTo() checks
    trialDays: 7,
    automaticTax: true,
    collectionMethod: CollectionMethod::ChargeAutomatically, // or SendInvoice + daysUntilDue
    paymentBehavior: PaymentBehavior::DefaultIncomplete,
    metadata: ['company' => (string) $company->getKey()],
), idempotencyKey: "start-{$company->getKey()}");

Odpoveď sa hneď uloží lokálne cez pipeline webhookov, s vlastníkom a názvom name; pri aktívnom predplatnom alebo predplatnom v skúšobnom období sa spustí SubscriptionStarted. Voľby, ktoré necháte null, sa neposielajú, takže platia predvolené hodnoty Stripe.

Flexibilný billing mode a zmiešané intervaly

use RoundlyConsulting\Purchases\Providers\Stripe\Enums\BillingMode;

Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
    prices: [
        SubscriptionPrice::licensed('price_licence_yearly'),   // billed every year
        SubscriptionPrice::metered('price_api_calls_monthly'), // usage, billed every month
    ],
    name: 'platform',
    billingMode: BillingMode::Flexible,   // billing_mode[type]=flexible
));
  • billingMode posiela billing_mode[type]; ak nie je nastavený, použije sa predvolená hodnota purchases.settings.stripe.billing_mode, inak sa nepošle nič — Stripe má od verzie API 2025-09-30.clover predvolene flexible, teda už na pripnutej 2026-05-27.dahlia. Billing mode vyžaduje 2025-06-30.basil alebo novšiu: pri staršej pripnutej api_version volanie vyhodí BillingException skôr, než sa opýta Stripe. Predplatné prejde z classic na flexible, nikdy späť.
  • Zmiešané intervaly vyžadujú flexibilný režim a interval každej položky musí byť násobkom najkratšieho (1 mesiac + 1 rok; nie týždeň + mesiac). Stripe účtuje každú položku za jej vlastné obdobie.
  • Lokálne je ends_at najneskorší koniec obdobia položky — dátum, do ktorého je zaplatená ročná licencia; vlastné obdobie každej položky je v meta.
  • Checkout predplatné so zmiešanými intervalmi vytvoriť nevie (použite create()) a cancelAtPeriodEnd() ho zruší k najskoršiemu koncu obdobia položky.

Skúšobné obdobie bez platobnej metódy

use RoundlyConsulting\Purchases\Providers\Stripe\Enums\MissingPaymentMethod;

Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
    prices: [SubscriptionPrice::licensed('price_team')],
    trialDays: 14,
    trialEndBehavior: MissingPaymentMethod::Pause,   // trial_settings[end_behavior][missing_payment_method]
));

Skúšobné obdobie platobnú metódu nepotrebuje. Čo sa stane, keď skončí bez nej, určuje trialEndBehavior — inak purchases.settings.stripe.missing_payment_method, inak create_invoice zo Stripe. trialEndBehavior bez skúšobného obdobia je BillingException:

MissingPaymentMethodV StripeLokálne
Cancelhneď ho zruší (customer.subscription.deleted)Canceled, SubscriptionCanceled
Pausepozastaví ho; až do obnovenia žiadne faktúry (customer.subscription.paused)OnHold, SubscriptionOnHold; po obnovení späť do Completed so SubscriptionResumed + SubscriptionRenewed
CreateInvoiceaj tak vystaví faktúru (predvolené v Stripe); nezaplatené prejde do past_dueInGracePeriod (alebo OnHold pri settings.stripe.past_due = on_hold)

Tri dni pred koncom skúšobného obdobia pošle Stripe customer.subscription.trial_will_end, ktorý spustí SubscriptionTrialWillEnd — vhodný moment poslať zákazníka do Customer Portalu, aby pridal platobnú metódu. Pozastavené predplatné sa obnoví z Portalu alebo z hostovanej stránky faktúry; balík volanie na endpoint /resume v Stripe nemá.

Jedno predplatné na vlastníka

// config/purchases.php — or PURCHASES_ONE_SUBSCRIPTION_PER_OWNER=true
'subscriptions' => [
    'one_per_owner' => true,
    'lock' => ['store' => 'redis', 'wait' => 10],   // a store with atomic locks, shared by every server
],

Purchases::for($company)->hasOngoingSubscription();   // what the limit checks
Subscription::query()->ongoing()->get();              // every status but canceled, failed and refunded
  • Pri zapnutom purchases.subscriptions.one_per_owner vyhodia create() aj checkout() BillingException a nič neodošlú, kým má vlastník predplatné, ktoré neskončilo — v akomkoľvek stave okrem canceled, failed (expirované) a refunded, od ľubovoľného poskytovateľa. Predplatné zrušené ku koncu obdobia sa ráta, kým neskončí.
  • Kontrola, volanie Stripe aj lokálne uloženie bežia pod jedným atomickým cache zámkom na vlastníka, takže z dvoch súbežných spustení druhé počká najviac lock.wait sekúnd, uvidí prvé a odmietne sa — alebo, ak zámok zostane obsadený, vyhodí BillingException („Another subscription is being started“).
  • Checkout vytvorí predplatné až po zaplatení, takže dve relácie otvorené pred zaplatením ho môžu spustiť obe — zapnite preto aj nastavenie Checkout v Stripe „Limit customers to one subscription“. Predplatné spustené mimo balíka sa nikdy neblokuje a webhooky vždy uložia, čo Stripe nahlási.

Checkout v režime predplatného

use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\CheckoutSubscriptionData;
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\PaymentMethodCollection;

$session = Purchases::stripe()->subscriptions()->checkout($company, new CheckoutSubscriptionData(
    prices: [SubscriptionPrice::licensed('price_seats', 10), SubscriptionPrice::metered('price_storage_gb')],
    successUrl: route('billing.done').'?session={CHECKOUT_SESSION_ID}',
    cancelUrl: route('billing'),
    trialDays: 7,
    paymentMethodCollection: PaymentMethodCollection::IfRequired,   // a free trial without a card
    trialEndBehavior: MissingPaymentMethod::Cancel,
    automaticTax: true,          // also sends customer_update[address]=auto, which Stripe Tax needs
));

return redirect($session->url);

Predplatné, ktoré Checkout vytvorí, príde cez webhook a s vlastníkom sa prepojí cez svojho zákazníka. PaymentMethodCollection::IfRequired si platobnú metódu vypýta, len keď je niečo splatné hneď, takže bezplatné skúšobné obdobie začne bez nej; trialEndBehavior a billingMode idú do subscription_data a padajú na tie isté predvolené hodnoty z konfigurácie ako create().

Zmeny, zrušenie, merače a Portal

use RoundlyConsulting\Purchases\Providers\Stripe\Enums\ProrationBehavior;

$seats = $subscription->itemFor('price_seats');                       // the stored item for a price

Purchases::stripe()->subscriptions()->updateQuantity($seats, $company->members()->count(), ProrationBehavior::CreateProrations);
Purchases::stripe()->subscriptions()->swap($seats, 'price_seats_annual', quantity: 11, proration: ProrationBehavior::AlwaysInvoice);
Purchases::stripe()->subscriptions()->addItem($subscription, 'price_api_calls');          // metered: no quantity
Purchases::stripe()->subscriptions()->removeItem('si_…', ProrationBehavior::None, clearUsage: true);

Purchases::stripe()->subscriptions()->cancelAtPeriodEnd($subscription);   // POST cancel_at_period_end=true
Purchases::stripe()->subscriptions()->resume($subscription);              // POST cancel_at_period_end=false
Purchases::stripe()->subscriptions()->cancel($subscription, invoiceNow: true, prorate: true); // DELETE, now

Každá zmena je jedno POST /v1/subscriptions/{id}, uloží sa lokálne a pri novom počte či cene sa spustí SubscriptionUpdated. ProrationBehavior je CreateProrations (predvolené), AlwaysInvoice alebo None; viac zmien naraz pošlete jedným change() so SubscriptionChange a preview() ukáže faktúru ešte pred potvrdením. resume() vezme späť zrušenie naplánované ku koncu obdobia — nie je to endpoint /resume v Stripe.

use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\MeterData;
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\MeterAggregation;

$meter = Purchases::stripe()->meters()->findOrCreate(new MeterData(
    eventName: 'storage_gb_days',
    displayName: 'Storage (GB-days)',
    aggregation: MeterAggregation::Sum,
));

Purchases::stripe()->meters()->report(
    $company,                                   // an owner with a Stripe customer, or 'cus_…'
    'storage_gb_days',
    12.5,                                       // int, float or decimal string — sent as an exact decimal string
    timestamp: now(),
    identifier: "storage-{$company->id}-".today()->toDateString(),
);

$session = Purchases::stripe()->portal($company, returnUrl: route('settings.billing'));
return redirect($session->url);

Spotreba sa hlási do Stripe Billing Meters ako presný desatinný reťazec; identifier (ak ho nezadáte, vygeneruje sa) slúži zároveň ako kľúč idempotencie a queue() zafixuje zákazníka, identifier aj čas už pri zaradení do fronty. URL Portalu platí krátko, preto ju vytvorte pri každej návšteve.

Chyby

Každé zlyhanie Stripe je typovaná StripeException — karta, neplatná požiadavka, autentifikácia, limit požiadaviek, idempotencia, API alebo spojenie — s httpStatus, stripeType, stripeCode, declineCode, param, requestId a idempotencyKey. Správa je od Stripe, pričom všetko, čo vyzerá ako tajný kľúč, sa nahradí [redacted], a žiadna výnimka si neponechá HTTP odpoveď. Problémy zistené pred odoslaním vyhodia BillingException; pozrite Enumy a výnimky.

Prejavte lásku k open source

Tento balík je zadarmo pod licenciou MIT. Ak vám šetrí čas, jednorazový príspevok alebo členstvo na Patreone nám pomôže ho ďalej udržiavať, testovať a dokumentovať.

Ďalšie spôsoby podpory vrátane kryptomien

Odoslaním daru súhlasíte s našimi podmienkami prijímania darov.

Chcete to zabudovať do svojho produktu?

Naše balíky integrujeme do zákazkových Laravel a AI riešení. Napíšte nám, na čom pracujete, a ozveme sa do 48 hodín.