geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth.
This commit is contained in:
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,amount:float,currency:string,paddle_price_id?:string|null,can_checkout:bool}>
|
||||
*/
|
||||
public function tiers(): array
|
||||
{
|
||||
return collect(config('gift-vouchers.tiers', []))
|
||||
->map(function (array $tier): array {
|
||||
$currency = Str::upper($tier['currency'] ?? 'EUR');
|
||||
$priceId = $tier['paddle_price_id'] ?? null;
|
||||
|
||||
return [
|
||||
'key' => $tier['key'],
|
||||
'label' => $tier['label'],
|
||||
'amount' => (float) $tier['amount'],
|
||||
'currency' => $currency,
|
||||
'paddle_price_id' => $priceId,
|
||||
'can_checkout' => ! empty($priceId),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{tier_key:string,purchaser_email:string,recipient_email?:string|null,recipient_name?:string|null,message?:string|null,success_url?:string|null,return_url?:string|null} $data
|
||||
* @return array{checkout_url:?string,expires_at:?string,id:?string}
|
||||
*/
|
||||
public function create(array $data): array
|
||||
{
|
||||
$tier = $this->findTier($data['tier_key']);
|
||||
|
||||
if (! $tier || empty($tier['paddle_price_id'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Gift voucher is not available right now.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $tier['paddle_price_id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'customer_email' => $data['purchaser_email'],
|
||||
'metadata' => array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'app_locale' => App::getLocale(),
|
||||
]),
|
||||
'success_url' => $data['success_url'] ?? route('marketing.success', ['locale' => App::getLocale(), 'type' => 'gift']),
|
||||
'cancel_url' => $data['return_url'] ?? route('packages', ['locale' => App::getLocale()]),
|
||||
];
|
||||
|
||||
$response = $this->client->post('/checkout/links', $payload);
|
||||
|
||||
return [
|
||||
'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'),
|
||||
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function findTier(string $key): ?array
|
||||
{
|
||||
$tiers = collect(config('gift-vouchers.tiers', []))
|
||||
->keyBy('key');
|
||||
|
||||
$tier = $tiers->get($key);
|
||||
|
||||
if (! $tier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tier['currency'] = Str::upper($tier['currency'] ?? 'EUR');
|
||||
|
||||
return $tier;
|
||||
}
|
||||
}
|
||||
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
|
||||
/**
|
||||
* Create a voucher from a Paddle transaction payload.
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
$amount = $this->resolveAmount($payload);
|
||||
$currency = Str::upper($this->resolveCurrency($payload));
|
||||
|
||||
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
|
||||
|
||||
$voucher = GiftVoucher::query()->updateOrCreate(
|
||||
[
|
||||
'paddle_transaction_id' => $payload['id'] ?? null,
|
||||
],
|
||||
[
|
||||
'code' => $metadata['gift_code'] ?? $this->generateCode(),
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'),
|
||||
'recipient_email' => $metadata['recipient_email'] ?? null,
|
||||
'recipient_name' => $metadata['recipient_name'] ?? null,
|
||||
'message' => $metadata['message'] ?? null,
|
||||
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'),
|
||||
'paddle_price_id' => $priceId,
|
||||
'metadata' => $metadata,
|
||||
'expires_at' => $expiresAt,
|
||||
'refunded_at' => null,
|
||||
'redeemed_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $voucher->coupon_id) {
|
||||
$coupon = $this->createCouponForVoucher($voucher);
|
||||
$voucher->forceFill(['coupon_id' => $coupon->id])->save();
|
||||
SyncCouponToPaddle::dispatch($coupon);
|
||||
}
|
||||
|
||||
return $voucher;
|
||||
}
|
||||
|
||||
public function markRedeemed(?Coupon $coupon, ?string $transactionId = null): void
|
||||
{
|
||||
if (! $coupon?->giftVoucher) {
|
||||
return;
|
||||
}
|
||||
|
||||
$voucher = $coupon->giftVoucher;
|
||||
|
||||
if ($voucher->isRedeemed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$voucher->forceFill([
|
||||
'status' => GiftVoucher::STATUS_REDEEMED,
|
||||
'redeemed_at' => now(),
|
||||
'metadata' => array_merge($voucher->metadata ?? [], array_filter([
|
||||
'redeemed_transaction_id' => $transactionId,
|
||||
])),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(GiftVoucher $voucher, ?string $reason = null): array
|
||||
{
|
||||
if (! $voucher->canBeRefunded()) {
|
||||
throw ValidationException::withMessages([
|
||||
'voucher' => __('Voucher cannot be refunded after redemption or refund.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $voucher->paddle_transaction_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'voucher' => __('Missing Paddle transaction for refund.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([
|
||||
'reason' => $reason,
|
||||
]));
|
||||
|
||||
$voucher->forceFill([
|
||||
'status' => GiftVoucher::STATUS_REFUNDED,
|
||||
'refunded_at' => now(),
|
||||
])->save();
|
||||
|
||||
if ($voucher->coupon) {
|
||||
$voucher->coupon->forceFill([
|
||||
'status' => CouponStatus::ARCHIVED,
|
||||
'enabled_for_checkout' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function createCouponForVoucher(GiftVoucher $voucher): Coupon
|
||||
{
|
||||
$packages = $this->eligiblePackages();
|
||||
|
||||
$coupon = Coupon::create([
|
||||
'name' => 'Gutschein '.$voucher->code,
|
||||
'code' => $voucher->code,
|
||||
'type' => CouponType::FLAT,
|
||||
'amount' => $voucher->amount,
|
||||
'currency' => $voucher->currency,
|
||||
'status' => CouponStatus::ACTIVE,
|
||||
'enabled_for_checkout' => true,
|
||||
'is_stackable' => false,
|
||||
'usage_limit' => 1,
|
||||
'per_customer_limit' => 1,
|
||||
'auto_apply' => false,
|
||||
'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.',
|
||||
'starts_at' => now(),
|
||||
'ends_at' => $voucher->expires_at,
|
||||
]);
|
||||
|
||||
if ($packages->isNotEmpty()) {
|
||||
$coupon->packages()->sync($packages->pluck('id'));
|
||||
}
|
||||
|
||||
return $coupon;
|
||||
}
|
||||
|
||||
protected function eligiblePackages(): Collection
|
||||
{
|
||||
$types = (array) config('gift-vouchers.package_types', ['endcustomer']);
|
||||
|
||||
return Package::query()
|
||||
->whereIn('type', $types)
|
||||
->whereNotNull('paddle_price_id')
|
||||
->get(['id']);
|
||||
}
|
||||
|
||||
protected function resolvePriceId(array $payload): ?string
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
|
||||
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
|
||||
return $metadata['paddle_price_id'];
|
||||
}
|
||||
|
||||
$items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', []));
|
||||
if (is_array($items) && isset($items[0]['price_id'])) {
|
||||
return $items[0]['price_id'];
|
||||
}
|
||||
|
||||
return $payload['price_id'] ?? null;
|
||||
}
|
||||
|
||||
protected function resolveAmount(array $payload): float
|
||||
{
|
||||
$tiers = Collection::make(config('gift-vouchers.tiers', []))
|
||||
->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null);
|
||||
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
if ($priceId && $tiers->has($priceId)) {
|
||||
return (float) $tiers->get($priceId)['amount'];
|
||||
}
|
||||
|
||||
$amount = Arr::get($payload, 'totals.grand_total.amount')
|
||||
?? Arr::get($payload, 'totals.grand_total')
|
||||
?? Arr::get($payload, 'details.totals.grand_total.amount')
|
||||
?? Arr::get($payload, 'details.totals.grand_total')
|
||||
?? Arr::get($payload, 'amount');
|
||||
|
||||
if (is_numeric($amount)) {
|
||||
$value = (float) $amount;
|
||||
|
||||
return $value >= 100 ? round($value / 100, 2) : round($value, 2);
|
||||
}
|
||||
|
||||
Log::warning('[GiftVoucher] Unable to resolve amount, defaulting to 0', ['payload' => $payload]);
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
protected function resolveCurrency(array $payload): string
|
||||
{
|
||||
return $payload['currency_code']
|
||||
?? Arr::get($payload, 'details.totals.currency_code')
|
||||
?? Arr::get($payload, 'currency')
|
||||
?? 'EUR';
|
||||
}
|
||||
|
||||
protected function generateCode(): string
|
||||
{
|
||||
return 'GIFT-'.Str::upper(Str::random(8));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user