feat: revenue cat
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user