Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.23% |
49 / 52 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
| InvestorStripeService | |
94.23% |
49 / 52 |
|
75.00% |
6 / 8 |
19.07 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| ensureCustomerExists | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| createSetupIntentClientSecret | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| listPaymentMethods | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| detachPaymentMethod | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| setDefaultPaymentMethod | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| chargeLoanPayment | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| assertInvestorOwnsPaymentMethod | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Stripe\Service; |
| 6 | |
| 7 | use App\Domain\Exception\ConflictException; |
| 8 | use App\Domain\Exception\NotFoundException; |
| 9 | use App\Domain\Exception\ValidationException; |
| 10 | use App\Domain\Investor\Repository\InvestorRepository; |
| 11 | use App\Domain\Stripe\Data\PaymentChargeData; |
| 12 | use 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 | */ |
| 22 | final 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 | } |