feat: revenue cat
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Image (push) Successful in 1m13s

This commit is contained in:
2026-08-12 16:00:11 +02:00
parent 31633cfd2c
commit 29e85589b0
55 changed files with 703 additions and 2363 deletions
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Services;
use App\Mail\PaymentInvoiceMail;
use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PaymentInvoiceEmailer
{
public function sendIfAvailable(PaymentTransaction $transaction): void
{
if ($transaction->invoice_email_sent_at !== null) {
return;
}
if (! $transaction->user || ! $transaction->user->email) {
return;
}
if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) {
return;
}
try {
Mail::to($transaction->user->email)->send(
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct'))
);
} catch (Throwable $exception) {
$transaction->forceFill([
'error_message' => 'Invoice email failed: '.$exception->getMessage(),
])->save();
return;
}
$transaction->forceFill([
'invoice_email_sent_at' => now(),
])->save();
}
}
@@ -1,96 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
class PaymentTransactionRecorder
{
/**
* @param array<string, mixed>|null $payload
*/
public function recordCheckoutSession(
?User $user,
?BillingProduct $product,
string $stripeCheckoutSessionId,
PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING,
?string $stripeEventId = null,
?string $stripeEventType = null,
?string $stripeCustomerId = null,
?string $stripePaymentIntentId = null,
?int $amount = null,
?string $currency = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
?array $payload = null,
): PaymentTransaction {
return PaymentTransaction::query()->updateOrCreate([
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
], [
'user_id' => $user?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::CHECKOUT,
'status' => $status,
'stripe_event_id' => $stripeEventId,
'stripe_event_type' => $stripeEventType,
'stripe_customer_id' => $stripeCustomerId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'amount' => $amount,
'currency' => $currency,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
'payload' => $payload,
'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(),
]);
}
/**
* @param array<string, mixed>|null $payload
*/
public function recordInvoice(
?User $user,
?BillingProduct $product,
string $stripeInvoiceId,
?string $stripePriceId,
PaymentTransactionStatus $status,
?string $stripeEventId = null,
?string $stripeEventType = null,
?string $stripeCustomerId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?int $amount = null,
?string $currency = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
?array $payload = null,
): PaymentTransaction {
return PaymentTransaction::query()->updateOrCreate([
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_price_id' => $stripePriceId,
], [
'user_id' => $user?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
'status' => $status,
'stripe_event_id' => $stripeEventId,
'stripe_event_type' => $stripeEventType,
'stripe_customer_id' => $stripeCustomerId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'stripe_subscription_id' => $stripeSubscriptionId,
'amount' => $amount,
'currency' => $currency,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
'payload' => $payload,
'processed_at' => now(),
]);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class RevenueCatService
{
public function sync(User $user): User
{
$response = $this->client()
->get('subscribers/'.rawurlencode((string) $user->getKey()))
->throw()
->json();
$subscriber = Arr::get($response, 'subscriber', []);
if (! is_array($subscriber)) {
throw new RuntimeException('RevenueCat returned an invalid subscriber payload.');
}
$subscription = $this->entitlement(
$subscriber,
(string) config('billing.revenuecat.subscription_entitlement'),
);
$certification = $this->entitlement(
$subscriber,
(string) config('billing.revenuecat.certification_entitlement'),
);
$subscriptionProductId = $this->stringValue($subscription['product_identifier'] ?? null);
$subscriptionDetails = $subscriptionProductId
? Arr::get($subscriber, "subscriptions.{$subscriptionProductId}", [])
: [];
$user->forceFill([
'subscription_product_id' => $this->entitlementIsActive($subscription)
? $subscriptionProductId
: null,
'subscription_store' => $this->entitlementIsActive($subscription)
? $this->stringValue(is_array($subscriptionDetails) ? ($subscriptionDetails['store'] ?? null) : null)
: null,
'subscription_expires_at' => $this->entitlementIsActive($subscription)
? $this->effectiveExpiration($subscription)
: null,
'subscription_is_trial' => $this->entitlementIsActive($subscription)
&& is_array($subscriptionDetails)
&& strtoupper((string) ($subscriptionDetails['period_type'] ?? '')) === 'TRIAL',
'certification_purchased_at' => $this->entitlementIsActive($certification)
? $this->dateValue($certification['purchase_date'] ?? null) ?? now()
: null,
])->save();
return $user->fresh();
}
public function deleteCustomer(User $user): void
{
if (trim((string) config('billing.revenuecat.secret_key')) === '') {
return;
}
$response = $this->client()
->delete('subscribers/'.rawurlencode((string) $user->getKey()));
if (! $response->notFound()) {
$response->throw();
}
}
private function client(): PendingRequest
{
$secretKey = trim((string) config('billing.revenuecat.secret_key'));
if ($secretKey === '') {
throw new RuntimeException('REVENUECAT_SECRET_KEY is not configured.');
}
return Http::baseUrl(rtrim((string) config('billing.revenuecat.api_url'), '/'))
->withToken($secretKey)
->acceptJson()
->connectTimeout(5)
->timeout(12)
->retry([200, 500], throw: false);
}
/**
* @param array<string, mixed> $subscriber
* @return array<string, mixed>
*/
private function entitlement(array $subscriber, string $identifier): array
{
$entitlement = Arr::get($subscriber, "entitlements.{$identifier}", []);
return is_array($entitlement) ? $entitlement : [];
}
/**
* @param array<string, mixed> $entitlement
*/
private function entitlementIsActive(array $entitlement): bool
{
if ($entitlement === []) {
return false;
}
$expiration = $this->effectiveExpiration($entitlement);
return $expiration === null || $expiration->isFuture();
}
/**
* @param array<string, mixed> $entitlement
*/
private function effectiveExpiration(array $entitlement): ?CarbonImmutable
{
$expiration = $this->dateValue($entitlement['expires_date'] ?? null);
$gracePeriodExpiration = $this->dateValue($entitlement['grace_period_expires_date'] ?? null);
if ($expiration === null) {
return $gracePeriodExpiration;
}
if ($gracePeriodExpiration === null) {
return $expiration;
}
return $gracePeriodExpiration->greaterThan($expiration)
? $gracePeriodExpiration
: $expiration;
}
private function dateValue(mixed $value): ?CarbonImmutable
{
if (! is_string($value) || $value === '') {
return null;
}
return CarbonImmutable::parse($value);
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}
@@ -1,96 +0,0 @@
<?php
namespace App\Services;
use App\Enums\BillingProductPurpose;
use App\Models\BillingProduct;
use Laravel\Cashier\Cashier;
class StripeBillingProductSyncer
{
public function sync(BillingProduct $product): BillingProduct
{
throw_if(
$product->purpose === null,
\InvalidArgumentException::class,
'A billing product purpose is required before syncing with Stripe.',
);
$stripe = Cashier::stripe();
$stripeProductId = $product->stripe_product_id;
if (! $stripeProductId) {
$stripeProduct = $stripe->products->create([
'name' => $product->name,
'description' => $product->description,
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
$stripeProductId = $stripeProduct->id;
} else {
$stripe->products->update($stripeProductId, [
'name' => $product->name,
'description' => $product->description,
'active' => (bool) $product->is_active,
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
}
$needsNewPrice = false;
if (! $product->stripe_price_id) {
$needsNewPrice = true;
} elseif ($product->wasChanged(['amount', 'currency', 'purpose'])) {
$needsNewPrice = true;
}
$stripePriceId = $product->stripe_price_id;
if ($needsNewPrice) {
$priceData = [
'product' => $stripeProductId,
'unit_amount' => $product->amount,
'currency' => strtolower($product->currency),
'metadata' => [
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
];
if ($product->purpose === BillingProductPurpose::SUBSCRIPTION) {
$priceData['recurring'] = [
'interval' => 'month',
];
}
$stripePrice = $stripe->prices->create($priceData);
$stripePriceId = $stripePrice->id;
// Archive the old price if it exists
if ($product->stripe_price_id && $product->stripe_price_id !== $stripePriceId) {
try {
$stripe->prices->update($product->stripe_price_id, [
'active' => false,
]);
} catch (\Exception $e) {
// Ignore error if price not found
}
}
}
if ($product->stripe_product_id !== $stripeProductId || $product->stripe_price_id !== $stripePriceId) {
$product->forceFill([
'stripe_product_id' => $stripeProductId,
'stripe_price_id' => $stripePriceId,
])->saveQuietly();
}
return $product;
}
}