Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.23% covered (success)
94.23%
49 / 52
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
InvestorStripeService
94.23% covered (success)
94.23%
49 / 52
75.00% covered (warning)
75.00%
6 / 8
19.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureCustomerExists
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 createSetupIntentClientSecret
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 listPaymentMethods
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 detachPaymentMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultPaymentMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 chargeLoanPayment
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 assertInvestorOwnsPaymentMethod
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Stripe\Service;
6
7use App\Domain\Exception\ConflictException;
8use App\Domain\Exception\NotFoundException;
9use App\Domain\Exception\ValidationException;
10use App\Domain\Investor\Repository\InvestorRepository;
11use App\Domain\Stripe\Data\PaymentChargeData;
12use App\Domain\Stripe\Data\PaymentMethodData;
13
14/**
15 * Bridge between FlowState investors and their Stripe Customer objects.
16 *
17 * Customers are created lazily — first call when an investor takes a loan
18 * (per FSC-92 design, see project_stripe_integration memory). Subsequent
19 * calls are idempotent and return the previously persisted ID without
20 * hitting Stripe.
21 */
22final readonly class InvestorStripeService
23{
24    public function __construct(
25        private InvestorRepository $investors,
26        private StripeClientInterface $stripe,
27    ) {}
28
29    /**
30     * Returns the investor's Stripe Customer ID, creating the Customer in
31     * Stripe and persisting the ID if one does not yet exist.
32     *
33     * @param int $investorId
34     * @throws NotFoundException When the investor row does not exist.
35     */
36    public function ensureCustomerExists(int $investorId): string
37    {
38        $existing = $this->investors->getStripeCustomerId($investorId);
39        if ($existing !== null) {
40            return $existing;
41        }
42
43        $investor = $this->investors->findInvestorById($investorId);
44        if ($investor === null) {
45            throw new NotFoundException("Investor {$investorId} not found");
46        }
47
48        $customer = $this->stripe->createCustomer(
49            email: $investor->email,
50            name: trim("{$investor->firstName} {$investor->lastName}"),
51            metadata: ['investor_id' => (string)$investorId],
52        );
53
54        $this->investors->setStripeCustomerId($investorId, $customer->id);
55
56        return $customer->id;
57    }
58
59    /**
60     * Create a SetupIntent for the investor's Customer (creating the
61     * Customer first if needed). Returns the client_secret the frontend
62     * passes to Stripe.js to confirm card details.
63     * @param int $investorId
64     */
65    public function createSetupIntentClientSecret(int $investorId): string
66    {
67        $customerId = $this->ensureCustomerExists($investorId);
68
69        return $this->stripe->createSetupIntent($customerId)->clientSecret;
70    }
71
72    /**
73     * @param int $investorId
74     * @return list<PaymentMethodData>
75     */
76    public function listPaymentMethods(int $investorId): array
77    {
78        $customerId = $this->investors->getStripeCustomerId($investorId);
79        if ($customerId === null) {
80            // No Customer yet ⇒ no payment methods. Don't lazy-create here;
81            // creating a Stripe Customer just to list zero PMs is wasteful.
82            return [];
83        }
84
85        return $this->stripe->listPaymentMethods($customerId);
86    }
87
88    /**
89     * Detach a PaymentMethod, verifying the PM belongs to the investor's
90     * Customer first. Without this check, knowing any PM id (including
91     * another investor's) would be enough to detach it via the secret key.
92     *
93     * @param int $investorId
94     * @param string $paymentMethodId
95     * @throws NotFoundException When the PM does not belong to the investor.
96     */
97    public function detachPaymentMethod(int $investorId, string $paymentMethodId): void
98    {
99        $this->assertInvestorOwnsPaymentMethod($investorId, $paymentMethodId);
100        $this->stripe->detachPaymentMethod($paymentMethodId);
101    }
102
103    /**
104     * Set the default PaymentMethod for the investor's Customer, again
105     * verifying ownership before mutating Stripe.
106     *
107     * @param int $investorId
108     * @param string $paymentMethodId
109     * @throws NotFoundException When the PM does not belong to the investor.
110     */
111    public function setDefaultPaymentMethod(int $investorId, string $paymentMethodId): void
112    {
113        $customerId = $this->assertInvestorOwnsPaymentMethod($investorId, $paymentMethodId);
114        $this->stripe->setDefaultPaymentMethod($customerId, $paymentMethodId);
115    }
116
117    /**
118     * Charge the investor's default PaymentMethod for a loan payment.
119     *
120     * @param string $amountUsd  Dollars-and-cents (e.g. "1234.56"); converted to integer cents for Stripe
121     * @param array<string, string> $metadata Tags stored on the PaymentIntent for traceability
122     * @param int $investorId
123     * @param string $description
124     *
125     * @throws NotFoundException When the investor has no Stripe Customer yet
126     * @throws ConflictException When the customer has no default PaymentMethod on file
127     */
128    public function chargeLoanPayment(
129        int $investorId,
130        string $amountUsd,
131        string $description,
132        array $metadata = [],
133    ): PaymentChargeData {
134        $customerId = $this->investors->getStripeCustomerId($investorId);
135        if ($customerId === null) {
136            throw new ConflictException('Add a payment method before making a loan payment');
137        }
138
139        $defaultPmId = null;
140        foreach ($this->stripe->listPaymentMethods($customerId) as $pm) {
141            if ($pm->isDefault) {
142                $defaultPmId = $pm->id;
143                break;
144            }
145        }
146        if ($defaultPmId === null) {
147            throw new ConflictException('Add a payment method before making a loan payment');
148        }
149
150        if (!is_numeric($amountUsd)) {
151            throw new ValidationException("Invalid USD amount: {$amountUsd}");
152        }
153        $amountCents = (int)bcmul($amountUsd, '100', 0);
154
155        return $this->stripe->chargePaymentMethod(
156            customerId: $customerId,
157            paymentMethodId: $defaultPmId,
158            amountCents: $amountCents,
159            description: $description,
160            metadata: $metadata,
161        );
162    }
163
164    /**
165     * @param int $investorId
166     * @param string $paymentMethodId
167     * @throws NotFoundException
168     * @return string The customer id the PM is attached to.
169     */
170    private function assertInvestorOwnsPaymentMethod(int $investorId, string $paymentMethodId): string
171    {
172        $customerId = $this->investors->getStripeCustomerId($investorId);
173        if ($customerId === null) {
174            throw new NotFoundException('Payment method not found');
175        }
176
177        $owned = $this->stripe->listPaymentMethods($customerId);
178        foreach ($owned as $pm) {
179            if ($pm->id === $paymentMethodId) {
180                return $customerId;
181            }
182        }
183
184        throw new NotFoundException('Payment method not found');
185    }
186}