feat(api): add AI text-to-deck generation, wallet deposit structure, and profile/notification endpoints
Fills the gaps between the admin panel screens and the backend: a full free-text + slide-count + language -> AI-generated presentation flow with new slide-cloning logic in TemplateFillService, a "my presentations" list endpoint, a swappable payment-gateway structure for wallet top-ups (fake driver for now), and avatar upload / public-profile toggle / account deletion / password change / notification preference endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,128 @@ class OpenAiCompatibleTextGenerator
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a full slide outline from free-form text: a set of fixed "title_values"
|
||||
* (filled once for the whole deck) plus exactly $slideCount "slides", each carrying
|
||||
* its own values for the template's repeatable keys.
|
||||
*
|
||||
* @return array{title_values: array<string, string>, slides: array<int, array<string, string>>}
|
||||
*/
|
||||
public function generateOutline(Template $template, string $title, string $sourceText, int $slideCount, string $language, AiProvider $provider): array
|
||||
{
|
||||
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
|
||||
$fixedKeys = $textKeys->where('is_repeatable', false);
|
||||
$repeatableKeys = $textKeys->where('is_repeatable', true);
|
||||
|
||||
if ($repeatableKeys->isEmpty()) {
|
||||
throw new RuntimeException('کلید تکرارشوندهای برای این قالب تعریف نشده است.');
|
||||
}
|
||||
|
||||
$describeKeys = fn ($keys): string => $keys
|
||||
->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : ''))
|
||||
->implode("\n");
|
||||
|
||||
$fixedKeysDescription = $fixedKeys->isEmpty() ? 'هیچ' : $describeKeys($fixedKeys);
|
||||
$repeatableKeysDescription = $describeKeys($repeatableKeys);
|
||||
|
||||
$systemPrompt = <<<PROMPT
|
||||
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس عنوان و متن خامی که کاربر میدهد، یک ارائه به زبان "{$language}" شامل دقیقاً {$slideCount} اسلاید محتوایی طراحی کن.
|
||||
|
||||
فقط یک JSON معتبر با دقیقاً این ساختار برگردان، بدون هیچ توضیح اضافه:
|
||||
{"title_values": {...}, "slides": [{...}, ...]}
|
||||
|
||||
"slides" باید دقیقاً {$slideCount} عضو داشته باشد.
|
||||
|
||||
کلیدهای ثابت (یک بار برای کل ارائه، در title_values):
|
||||
{$fixedKeysDescription}
|
||||
|
||||
کلیدهای تکرارشونده (برای هر اسلاید، در هر عضو آرایه slides):
|
||||
{$repeatableKeysDescription}
|
||||
|
||||
نام کلیدها باید دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نامهای بالا باشند.
|
||||
PROMPT;
|
||||
|
||||
$userPrompt = "عنوان: {$title}\n\nمتن خام:\n{$sourceText}";
|
||||
|
||||
$response = Http::withToken($provider->api_key)
|
||||
->timeout(120)
|
||||
->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [
|
||||
'model' => $provider->model,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body());
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response->json('choices.0.message.content'), true);
|
||||
|
||||
if (! is_array($decoded) || ! is_array($decoded['slides'] ?? null)) {
|
||||
throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.');
|
||||
}
|
||||
|
||||
$normalizedTitleValues = $this->normalizeValueMap(is_array($decoded['title_values'] ?? null) ? $decoded['title_values'] : []);
|
||||
|
||||
$titleValues = [];
|
||||
|
||||
foreach ($fixedKeys as $key) {
|
||||
$normalizedKey = $this->normalizeKey($key->key);
|
||||
|
||||
if (array_key_exists($normalizedKey, $normalizedTitleValues)) {
|
||||
$titleValues[$key->key] = (string) $normalizedTitleValues[$normalizedKey];
|
||||
}
|
||||
}
|
||||
|
||||
$slides = [];
|
||||
|
||||
foreach ($decoded['slides'] as $slideData) {
|
||||
if (! is_array($slideData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedSlideData = $this->normalizeValueMap($slideData);
|
||||
$slideValues = [];
|
||||
|
||||
foreach ($repeatableKeys as $key) {
|
||||
$normalizedKey = $this->normalizeKey($key->key);
|
||||
|
||||
if (array_key_exists($normalizedKey, $normalizedSlideData)) {
|
||||
$slideValues[$key->key] = (string) $normalizedSlideData[$normalizedKey];
|
||||
}
|
||||
}
|
||||
|
||||
$slides[] = $slideValues;
|
||||
}
|
||||
|
||||
if ($slides === []) {
|
||||
throw new RuntimeException('هوش مصنوعی محتوایی برای اسلایدها تولید نکرد.');
|
||||
}
|
||||
|
||||
return [
|
||||
'title_values' => $titleValues,
|
||||
'slides' => array_slice($slides, 0, $slideCount),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed, mixed> $values
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeValueMap(array $values): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$normalized[$this->normalizeKey((string) $key)] = $value;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeKey(string $key): string
|
||||
{
|
||||
return mb_strtolower(trim($key, "{} \t\n\r\0\x0B"));
|
||||
|
||||
35
app/Services/Payments/FakePaymentGateway.php
Normal file
35
app/Services/Payments/FakePaymentGateway.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Contracts\PaymentGateway;
|
||||
use App\Models\WalletDeposit;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Stand-in gateway used until a real provider (e.g. ZarinPal, Zibal) is wired up.
|
||||
* It simulates an instant-success redirect straight to the callback route, so the
|
||||
* full initiate -> redirect -> callback -> credit flow is testable end-to-end
|
||||
* without any external credentials. Swap the PaymentGateway binding in
|
||||
* AppServiceProvider to replace it with a real driver.
|
||||
*/
|
||||
class FakePaymentGateway implements PaymentGateway
|
||||
{
|
||||
public function initiate(WalletDeposit $deposit): string
|
||||
{
|
||||
$deposit->update(['reference_id' => (string) Str::uuid()]);
|
||||
|
||||
return route('api.wallet.deposits.callback', [
|
||||
'deposit' => $deposit->id,
|
||||
'reference_id' => $deposit->reference_id,
|
||||
'status' => 'success',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verify(WalletDeposit $deposit, array $callbackPayload): bool
|
||||
{
|
||||
return $deposit->reference_id !== null
|
||||
&& ($callbackPayload['reference_id'] ?? null) === $deposit->reference_id
|
||||
&& ($callbackPayload['status'] ?? null) === 'success';
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ class TemplateFillService
|
||||
|
||||
private const NS_RELATIONSHIPS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
||||
|
||||
private const NS_CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
||||
|
||||
private const NS_PACKAGE_RELATIONSHIPS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
||||
|
||||
private const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
|
||||
|
||||
public function fill(TemplateGeneration $generation): string
|
||||
{
|
||||
$template = $generation->template;
|
||||
@@ -64,27 +70,8 @@ class TemplateFillService
|
||||
}
|
||||
|
||||
try {
|
||||
$slideEntries = $this->findSlideEntries($zip);
|
||||
|
||||
foreach ($slideEntries as $slideEntry) {
|
||||
$xml = $zip->getFromName($slideEntry);
|
||||
|
||||
if ($xml === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($textValues !== []) {
|
||||
$newXml = $this->replaceTextInSlideXml($xml, $textValues);
|
||||
|
||||
if ($newXml !== null) {
|
||||
$zip->addFromString($slideEntry, $newXml);
|
||||
$xml = $newXml;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($imageValues as $shapeName => $imagePath) {
|
||||
$this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath);
|
||||
}
|
||||
foreach ($this->findSlideEntries($zip) as $slideEntry) {
|
||||
$this->applyValuesToSlide($zip, $slideEntry, $textValues, $imageValues);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
@@ -93,6 +80,287 @@ class TemplateFillService
|
||||
return $outputRelativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presentation from AI-produced content by cloning the template's
|
||||
* designated repeatable slide once per outline slide, then filling each slide
|
||||
* (repeatable and fixed alike) with its corresponding text values. Unlike fill(),
|
||||
* this can change the slide count, since the outline's slide count is user-chosen.
|
||||
*
|
||||
* @param array{title_values: array<string, string>, slides: array<int, array<string, string>>} $outline
|
||||
*/
|
||||
public function fillAiGeneration(TemplateGeneration $generation, array $outline): string
|
||||
{
|
||||
$template = $generation->template;
|
||||
|
||||
if ($template->ai_repeatable_slide_number === null) {
|
||||
throw new RuntimeException('اسلاید تکرارشونده برای این قالب تنظیم نشده است.');
|
||||
}
|
||||
|
||||
$sourceAbsolutePath = Storage::disk('local')->path($template->file_path);
|
||||
|
||||
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
|
||||
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
|
||||
|
||||
Storage::disk('local')->makeDirectory('templates/generations');
|
||||
|
||||
if (! copy($sourceAbsolutePath, $outputAbsolutePath)) {
|
||||
throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.');
|
||||
}
|
||||
|
||||
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
|
||||
$repeatableKeys = $textKeys->where('is_repeatable', true);
|
||||
$fixedKeys = $textKeys->where('is_repeatable', false);
|
||||
|
||||
$titleValues = [];
|
||||
|
||||
foreach ($fixedKeys as $key) {
|
||||
if (array_key_exists($key->key, $outline['title_values'] ?? [])) {
|
||||
$titleValues[$key->key] = (string) $outline['title_values'][$key->key];
|
||||
}
|
||||
}
|
||||
|
||||
$slides = array_values($outline['slides'] ?? []);
|
||||
$repeatCount = count($slides);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($outputAbsolutePath) !== true) {
|
||||
throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.');
|
||||
}
|
||||
|
||||
try {
|
||||
$repeatableSlideEntry = sprintf('ppt/slides/slide%d.xml', $template->ai_repeatable_slide_number);
|
||||
$repeatableEntries = [$repeatableSlideEntry];
|
||||
|
||||
if ($repeatCount > 1) {
|
||||
$repeatableEntries = [...$repeatableEntries, ...$this->cloneSlide($zip, $repeatableSlideEntry, $repeatCount - 1)];
|
||||
}
|
||||
|
||||
foreach ($this->findSlideEntries($zip) as $slideEntry) {
|
||||
$repeatableIndex = array_search($slideEntry, $repeatableEntries, true);
|
||||
|
||||
if ($repeatableIndex === false) {
|
||||
$this->applyValuesToSlide($zip, $slideEntry, $titleValues, []);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$slideValues = [];
|
||||
|
||||
foreach ($repeatableKeys as $key) {
|
||||
if (array_key_exists($key->key, $slides[$repeatableIndex] ?? [])) {
|
||||
$slideValues[$key->key] = (string) $slides[$repeatableIndex][$key->key];
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyValuesToSlide($zip, $slideEntry, $slideValues, []);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return $outputRelativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $textValues
|
||||
* @param array<string, string> $imageValues
|
||||
*/
|
||||
private function applyValuesToSlide(ZipArchive $zip, string $slideEntry, array $textValues, array $imageValues): void
|
||||
{
|
||||
$xml = $zip->getFromName($slideEntry);
|
||||
|
||||
if ($xml === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($textValues !== []) {
|
||||
$newXml = $this->replaceTextInSlideXml($xml, $textValues);
|
||||
|
||||
if ($newXml !== null) {
|
||||
$zip->addFromString($slideEntry, $newXml);
|
||||
$xml = $newXml;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($imageValues as $shapeName => $imagePath) {
|
||||
$this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the given slide entry $times additional times, registering each clone in
|
||||
* presentation.xml, presentation.xml.rels and [Content_Types].xml so PowerPoint
|
||||
* recognizes them as real slides, positioned immediately after the source slide in
|
||||
* deck order. Internal shape/paragraph ids inside the cloned XML are left as-is —
|
||||
* they only need to be unique within their own slide part, not deck-wide.
|
||||
*
|
||||
* @return array<int, string> the newly created slide entry names, in order
|
||||
*/
|
||||
private function cloneSlide(ZipArchive $zip, string $sourceSlideEntry, int $times): array
|
||||
{
|
||||
if ($times <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceSlideXml = $zip->getFromName($sourceSlideEntry);
|
||||
|
||||
if ($sourceSlideXml === false) {
|
||||
throw new RuntimeException('اسلاید الگو برای شبیهسازی یافت نشد.');
|
||||
}
|
||||
|
||||
$sourceRelsEntry = 'ppt/slides/_rels/'.basename($sourceSlideEntry).'.rels';
|
||||
$sourceRelsXml = $zip->getFromName($sourceRelsEntry);
|
||||
|
||||
$presentationXml = $zip->getFromName('ppt/presentation.xml');
|
||||
$presentationRelsXml = $zip->getFromName('ppt/_rels/presentation.xml.rels');
|
||||
$contentTypesXml = $zip->getFromName('[Content_Types].xml');
|
||||
|
||||
if ($presentationXml === false || $presentationRelsXml === false || $contentTypesXml === false) {
|
||||
throw new RuntimeException('ساختار فایل پاورپوینت نامعتبر است.');
|
||||
}
|
||||
|
||||
$presentationDom = $this->loadXml($presentationXml);
|
||||
$relsDom = $this->loadXml($presentationRelsXml);
|
||||
$contentTypesDom = $this->loadXml($contentTypesXml);
|
||||
|
||||
if ($presentationDom === null || $relsDom === null || $contentTypesDom === null) {
|
||||
throw new RuntimeException('خواندن ساختار فایل پاورپوینت با خطا مواجه شد.');
|
||||
}
|
||||
|
||||
$sourceRelId = $this->findSlideRelationshipId($relsDom, $sourceSlideEntry);
|
||||
|
||||
if ($sourceRelId === null) {
|
||||
throw new RuntimeException('ارتباط اسلاید الگو با فایل ارائه یافت نشد.');
|
||||
}
|
||||
|
||||
$presentationXpath = new DOMXPath($presentationDom);
|
||||
$presentationXpath->registerNamespace('p', self::NS_PRESENTATION);
|
||||
$presentationXpath->registerNamespace('r', self::NS_RELATIONSHIPS);
|
||||
|
||||
$sourceSldIdEl = null;
|
||||
|
||||
foreach ($presentationXpath->query('//p:sldIdLst/p:sldId') as $sldIdEl) {
|
||||
if ($sldIdEl->getAttributeNS(self::NS_RELATIONSHIPS, 'id') === $sourceRelId) {
|
||||
$sourceSldIdEl = $sldIdEl;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceSldIdEl === null) {
|
||||
throw new RuntimeException('اسلاید الگو در فهرست اسلایدها یافت نشد.');
|
||||
}
|
||||
|
||||
$nextSlideNumber = $this->nextSlideNumber($zip);
|
||||
$nextRelIndex = $this->maxRelationshipId($relsDom) + 1;
|
||||
$nextSlideId = $this->maxSlideId($presentationDom) + 1;
|
||||
|
||||
$newEntries = [];
|
||||
$insertAfter = $sourceSldIdEl;
|
||||
|
||||
for ($i = 0; $i < $times; $i++) {
|
||||
$newSlideNumber = $nextSlideNumber++;
|
||||
$newSlideEntry = "ppt/slides/slide{$newSlideNumber}.xml";
|
||||
$newRelsEntry = "ppt/slides/_rels/slide{$newSlideNumber}.xml.rels";
|
||||
$newRelId = 'rId'.$nextRelIndex++;
|
||||
$newSlideId = (string) $nextSlideId++;
|
||||
|
||||
$zip->addFromString($newSlideEntry, $sourceSlideXml);
|
||||
|
||||
if ($sourceRelsXml !== false) {
|
||||
$zip->addFromString($newRelsEntry, $sourceRelsXml);
|
||||
}
|
||||
|
||||
$override = $contentTypesDom->createElementNS(self::NS_CONTENT_TYPES, 'Override');
|
||||
$override->setAttribute('PartName', '/'.$newSlideEntry);
|
||||
$override->setAttribute('ContentType', self::SLIDE_CONTENT_TYPE);
|
||||
$contentTypesDom->documentElement->appendChild($override);
|
||||
|
||||
$relationship = $relsDom->createElementNS(self::NS_PACKAGE_RELATIONSHIPS, 'Relationship');
|
||||
$relationship->setAttribute('Id', $newRelId);
|
||||
$relationship->setAttribute('Type', self::NS_RELATIONSHIPS.'/slide');
|
||||
$relationship->setAttribute('Target', 'slides/'.basename($newSlideEntry));
|
||||
$relsDom->documentElement->appendChild($relationship);
|
||||
|
||||
$sldId = $presentationDom->createElementNS(self::NS_PRESENTATION, 'p:sldId');
|
||||
$sldId->setAttribute('id', $newSlideId);
|
||||
$sldId->setAttributeNS(self::NS_RELATIONSHIPS, 'r:id', $newRelId);
|
||||
|
||||
$insertAfter->parentNode->insertBefore($sldId, $insertAfter->nextSibling);
|
||||
$insertAfter = $sldId;
|
||||
|
||||
$newEntries[] = $newSlideEntry;
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', $contentTypesDom->saveXML());
|
||||
$zip->addFromString('ppt/_rels/presentation.xml.rels', $relsDom->saveXML());
|
||||
$zip->addFromString('ppt/presentation.xml', $presentationDom->saveXML());
|
||||
|
||||
return $newEntries;
|
||||
}
|
||||
|
||||
private function findSlideRelationshipId(DOMDocument $relsDom, string $slideEntry): ?string
|
||||
{
|
||||
$target = 'slides/'.basename($slideEntry);
|
||||
|
||||
foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) {
|
||||
if ($relationship->getAttribute('Target') === $target) {
|
||||
return $relationship->getAttribute('Id');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function maxRelationshipId(DOMDocument $relsDom): int
|
||||
{
|
||||
$max = 0;
|
||||
|
||||
foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) {
|
||||
if (preg_match('/^rId(\d+)$/', $relationship->getAttribute('Id'), $matches)) {
|
||||
$max = max($max, (int) $matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private function maxSlideId(DOMDocument $presentationDom): int
|
||||
{
|
||||
$max = 0;
|
||||
|
||||
foreach ($presentationDom->getElementsByTagNameNS(self::NS_PRESENTATION, 'sldId') as $sldId) {
|
||||
$max = max($max, (int) $sldId->getAttribute('id'));
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private function nextSlideNumber(ZipArchive $zip): int
|
||||
{
|
||||
$max = 0;
|
||||
|
||||
foreach ($this->findSlideEntries($zip) as $entry) {
|
||||
if (preg_match('#slide(\d+)\.xml$#', $entry, $matches)) {
|
||||
$max = max($max, (int) $matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $max + 1;
|
||||
}
|
||||
|
||||
private function loadXml(string $xml): ?DOMDocument
|
||||
{
|
||||
$dom = new DOMDocument;
|
||||
$previousSetting = libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadXML($xml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousSetting);
|
||||
|
||||
return $loaded ? $dom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@@ -126,13 +394,9 @@ class TemplateFillService
|
||||
$lookup[$this->normalizeKey($key)] = $value;
|
||||
}
|
||||
|
||||
$dom = new DOMDocument;
|
||||
$previousSetting = libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadXML($xml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousSetting);
|
||||
$dom = $this->loadXml($xml);
|
||||
|
||||
if (! $loaded) {
|
||||
if ($dom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -208,13 +472,9 @@ class TemplateFillService
|
||||
|
||||
private function findImageRelationshipId(string $slideXml, string $shapeName): ?string
|
||||
{
|
||||
$dom = new DOMDocument;
|
||||
$previousSetting = libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadXML($slideXml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousSetting);
|
||||
$dom = $this->loadXml($slideXml);
|
||||
|
||||
if (! $loaded) {
|
||||
if ($dom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -263,13 +523,9 @@ class TemplateFillService
|
||||
return null;
|
||||
}
|
||||
|
||||
$dom = new DOMDocument;
|
||||
$previousSetting = libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadXML($relsXml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousSetting);
|
||||
$dom = $this->loadXml($relsXml);
|
||||
|
||||
if (! $loaded) {
|
||||
if ($dom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
61
app/Services/WalletDepositService.php
Normal file
61
app/Services/WalletDepositService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Contracts\PaymentGateway;
|
||||
use App\Enums\PaymentGatewayDriver;
|
||||
use App\Enums\WalletDepositStatus;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletDeposit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class WalletDepositService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WalletService $walletService,
|
||||
private readonly PaymentGateway $gateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{deposit: WalletDeposit, payment_url: string}
|
||||
*/
|
||||
public function initiate(User $user, int $amount): array
|
||||
{
|
||||
$wallet = $this->walletService->createForUser($user);
|
||||
|
||||
$deposit = WalletDeposit::query()->create([
|
||||
'wallet_id' => $wallet->id,
|
||||
'amount' => $amount,
|
||||
'status' => WalletDepositStatus::Pending,
|
||||
'gateway' => PaymentGatewayDriver::Fake->value,
|
||||
]);
|
||||
|
||||
$paymentUrl = $this->gateway->initiate($deposit);
|
||||
|
||||
return ['deposit' => $deposit, 'payment_url' => $paymentUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $callbackPayload
|
||||
*/
|
||||
public function handleCallback(WalletDeposit $deposit, array $callbackPayload): bool
|
||||
{
|
||||
if ($deposit->status !== WalletDepositStatus::Pending) {
|
||||
return $deposit->status === WalletDepositStatus::Paid;
|
||||
}
|
||||
|
||||
if (! $this->gateway->verify($deposit, $callbackPayload)) {
|
||||
$deposit->update(['status' => WalletDepositStatus::Failed]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($deposit): void {
|
||||
$this->walletService->credit($deposit->wallet, $deposit->amount, 'شارژ کیف پول');
|
||||
|
||||
$deposit->update(['status' => WalletDepositStatus::Paid]);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user