Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
StripeService
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 9
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createCustomer
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 findCustomer
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 createSetupIntent
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 listPaymentMethods
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 detachPaymentMethod
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDefaultPaymentMethod
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 chargePaymentMethod
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 parseWebhookEvent
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Stripe\Service;
6
7use App\Domain\Stripe\Data\PaymentChargeData;
8use App\Domain\Stripe\Data\PaymentMethodData;
9use App\Domain\Stripe\Data\SetupIntentData;
10use App\Domain\Stripe\Data\StripeCustomerData;
11use App\Domain\Stripe\Data\WebhookEventData;
12use App\Domain\Stripe\Exception\WebhookSignatureException;
13use Stripe\Exception\CardException;
14use Stripe\Exception\InvalidRequestException;
15use Stripe\Exception\SignatureVerificationException;
16use Stripe\StripeClient;
17use Stripe\Webhook;
18
19/**
20 * Real Stripe SDK wrapper. Translates between FlowState's domain DTOs and
21 * the Stripe SDK objects so callers never depend on \Stripe\* classes.
22 */
23final readonly class StripeService implements StripeClientInterface
24{
25    private StripeClient $stripe;
26
27    public function __construct(
28        string $secretKey,
29        private string $webhookSecret = '',
30    ) {
31        $this->stripe = new StripeClient($secretKey);
32    }
33
34    public function createCustomer(
35        ?string $email = null,
36        ?string $name = null,
37        array $metadata = [],
38    ): StripeCustomerData {
39        $params = [];
40        if ($email !== null) {
41            $params['email'] = $email;
42        }
43        if ($name !== null) {
44            $params['name'] = $name;
45        }
46        if ($metadata !== []) {
47            $params['metadata'] = $metadata;
48        }
49
50        $customer = $this->stripe->customers->create($params);
51
52        return new StripeCustomerData(
53            id: $customer->id,
54            email: $customer->email,
55            name: $customer->name,
56            createdAt: $customer->created,
57        );
58    }
59
60    public function findCustomer(string $customerId): ?StripeCustomerData
61    {
62        try {
63            $customer = $this->stripe->customers->retrieve($customerId);
64        } catch (InvalidRequestException $e) {
65            if ($e->getStripeCode() === 'resource_missing') {
66                return null;
67            }
68
69            throw $e;
70        }
71
72        return new StripeCustomerData(
73            id: $customer->id,
74            email: $customer->email,
75            name: $customer->name,
76            createdAt: $customer->created,
77        );
78    }
79
80    public function createSetupIntent(string $customerId): SetupIntentData
81    {
82        $intent = $this->stripe->setupIntents->create([
83            'customer' => $customerId,
84            'payment_method_types' => ['card'],
85            'usage' => 'off_session', // we'll charge later (loan payments)
86        ]);
87
88        return new SetupIntentData(
89            id: $intent->id,
90            clientSecret: (string)$intent->client_secret,
91            status: $intent->status,
92        );
93    }
94
95    public function listPaymentMethods(string $customerId): array
96    {
97        $customer = $this->stripe->customers->retrieve($customerId, [
98            'expand' => ['invoice_settings.default_payment_method'],
99        ]);
100        $defaultPmId = null;
101        $defaultPm = $customer->invoice_settings->default_payment_method ?? null;
102        if (is_string($defaultPm)) {
103            $defaultPmId = $defaultPm;
104        } elseif (is_object($defaultPm) && isset($defaultPm->id)) {
105            $defaultPmId = (string)$defaultPm->id;
106        }
107
108        $methods = $this->stripe->paymentMethods->all([
109            'customer' => $customerId,
110            'type' => 'card',
111        ]);
112
113        $result = [];
114        foreach ($methods->data as $pm) {
115            $card = $pm->card;
116            if ($card === null) {
117                continue;
118            }
119            $result[] = new PaymentMethodData(
120                id: $pm->id,
121                brand: (string)$card->brand,
122                last4: (string)$card->last4,
123                expMonth: (int)$card->exp_month,
124                expYear: (int)$card->exp_year,
125                isDefault: $pm->id === $defaultPmId,
126            );
127        }
128
129        return $result;
130    }
131
132    public function detachPaymentMethod(string $paymentMethodId): void
133    {
134        $this->stripe->paymentMethods->detach($paymentMethodId);
135    }
136
137    public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void
138    {
139        $this->stripe->customers->update($customerId, [
140            'invoice_settings' => [
141                'default_payment_method' => $paymentMethodId,
142            ],
143        ]);
144    }
145
146    public function chargePaymentMethod(
147        string $customerId,
148        string $paymentMethodId,
149        int $amountCents,
150        string $description,
151        array $metadata = [],
152    ): PaymentChargeData {
153        try {
154            $intent = $this->stripe->paymentIntents->create([
155                'amount' => $amountCents,
156                'currency' => 'usd',
157                'customer' => $customerId,
158                'payment_method' => $paymentMethodId,
159                'off_session' => true,
160                'confirm' => true,
161                'description' => $description,
162                'metadata' => $metadata,
163            ]);
164        } catch (CardException $e) {
165            // Stripe wraps declines (insufficient funds, do_not_honor, etc.)
166            // as CardException. Translate into a domain-shaped failure
167            // result so callers can present the message to the user.
168            $intentId = '';
169            if ($e->getStripeCode() !== null) {
170                $err = $e->getError();
171                if ($err !== null && isset($err->payment_intent->id)) {
172                    $intentId = (string)$err->payment_intent->id;
173                }
174            }
175
176            return new PaymentChargeData(
177                id: $intentId,
178                status: 'declined',
179                declineMessage: $e->getMessage(),
180            );
181        }
182
183        return new PaymentChargeData(
184            id: $intent->id,
185            status: $intent->status,
186            declineMessage: null,
187        );
188    }
189
190    public function parseWebhookEvent(string $payload, string $signature): WebhookEventData
191    {
192        if ($this->webhookSecret === '') {
193            // Defense in depth: refuse to "verify" if no secret is configured.
194            // In production this means a deploy without env config fails
195            // closed instead of accepting any payload.
196            throw new WebhookSignatureException('Webhook secret is not configured');
197        }
198
199        try {
200            $event = Webhook::constructEvent($payload, $signature, $this->webhookSecret);
201        } catch (SignatureVerificationException $e) {
202            throw new WebhookSignatureException('Invalid Stripe signature', 0, $e);
203        }
204
205        return new WebhookEventData(
206            id: $event->id,
207            type: $event->type,
208            payload: $event->toArray(),
209        );
210    }
211}