Files
boursecu_server/app/Services/SmsBilling/SmsWalletService.php
T
mohmdsdgandCursor 91a2dab440 fix(sms): keep charge deposit defaults and unlock referral bonus
Empty SuperAdmin chargeInfo was wiping sms_charge_* on header refresh.
Seed those keys only when empty, and move referral_bonus_amount to can_edit=1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:38:19 +03:30

390 lines
13 KiB
PHP

<?php
namespace App\Services\SmsBilling;
use App\Models\Setting;
use App\Models\SmsLedger;
use App\Models\SmsSendLog;
use App\Models\SmsWallet;
use App\Services\ConfigService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class SmsWalletService
{
public const SUPER_ADMIN_MOBILE = '09128081392';
public const TYPE_CHARGE = 'charge';
public const TYPE_DEBIT = 'debit';
public const TYPE_ALERT_DEBIT = 'alert_debit';
/** کلیدهای تنظیمات اطلاعات واریز برای شارژ سامانه پیامکی (یکسان برای همه). */
public const DEPOSIT_SETTING_KEYS = [
'card_number' => 'sms_charge_card_number',
'sheba' => 'sms_charge_sheba',
'account_holder' => 'sms_charge_account_holder',
'note' => 'sms_charge_note',
];
/** مقادیر پیش‌فرض اطلاعات واریز. */
public const DEPOSIT_DEFAULTS = [
'card_number' => '6219861961033817',
'sheba' => 'IR080560611828005603604201',
'account_holder' => 'حسین منتظر',
'note' => 'لطفا مبلغ مورد نظر خود جهت شارژ سامانه پیامکی را به شماره کارت یا حساب زیر واریز نمایید و تصویر فیش پرداختی را برای پشتیبانی بفرستید.',
];
/** Billing/panel فعال است مگر sms_panel صریحاً false باشد. */
public static function isBillingEnabled(): bool
{
$value = app(ConfigService::class)->getString('sms_panel');
if ($value === null || trim((string) $value) === '') {
return true;
}
$normalized = strtolower(trim((string) $value));
return ! in_array($normalized, ['false', '0', 'no', 'off'], true);
}
/** billing فعال است و جداول migration هم وجود دارند. */
public static function isBillingOperational(): bool
{
if (! self::isBillingEnabled()) {
return false;
}
try {
return Schema::hasTable('sms_wallets')
&& Schema::hasTable('sms_send_logs')
&& Schema::hasTable('sms_ledger');
} catch (\Throwable) {
return false;
}
}
public function getWallet(): SmsWallet
{
return SmsWallet::query()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
}
public function isBlocked(): bool
{
if (! self::isBillingOperational()) {
return false;
}
return (bool) $this->getWallet()->is_blocked;
}
public function setBlocked(bool $blocked): SmsWallet
{
$wallet = $this->getWallet();
$wallet->is_blocked = $blocked;
$wallet->save();
return $wallet->fresh();
}
/**
* Keep local MySQL wallet mirrored with SuperAdmin during the parallel period.
*/
public function mirrorFromSuperAdmin(?int $balance = null, ?int $pricePerPart = null, ?bool $isBlocked = null): SmsWallet
{
return DB::transaction(function () use ($balance, $pricePerPart, $isBlocked) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
if ($balance !== null) {
$wallet->balance = $balance;
}
if ($pricePerPart !== null) {
$wallet->price_per_part = max(0, $pricePerPart);
}
if ($isBlocked !== null) {
$wallet->is_blocked = $isBlocked;
}
if ($wallet->balance > 0) {
$wallet->alert_days_sent = 0;
$wallet->last_alert_date = null;
}
$wallet->save();
return $wallet->fresh();
});
}
public function updatePricePerPart(int $pricePerPart): SmsWallet
{
$wallet = $this->getWallet();
$wallet->price_per_part = max(0, $pricePerPart);
$wallet->save();
return $wallet;
}
public function charge(int $amount, ?int $adminUserId = null, ?string $note = null): SmsWallet
{
if ($amount <= 0) {
throw new \InvalidArgumentException('مبلغ شارژ باید بزرگتر از صفر باشد.');
}
return DB::transaction(function () use ($amount, $adminUserId, $note) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
$wallet->balance += $amount;
if ($wallet->balance > 0) {
$wallet->alert_days_sent = 0;
$wallet->last_alert_date = null;
}
$wallet->save();
SmsLedger::create([
'type' => self::TYPE_CHARGE,
'amount' => $amount,
'balance_after' => $wallet->balance,
'note' => $note,
'created_by' => $adminUserId,
'created_at' => now(),
]);
return $wallet;
});
}
/**
* After SuperAdmin consume: set local balance to SA value and write local send log/ledger
* so the product SMS panel stays in sync during the parallel period.
*/
public function mirrorConsumeFromSuperAdmin(
string $receiver,
string $body,
int $parts,
string $provider,
?string $source,
bool $isAlert,
int $balanceAfter,
int $costRials,
int $pricePerPart
): void {
DB::transaction(function () use (
$receiver,
$body,
$parts,
$provider,
$source,
$isAlert,
$balanceAfter,
$costRials,
$pricePerPart
) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
$wallet->balance = $balanceAfter;
if ($pricePerPart > 0) {
$wallet->price_per_part = $pricePerPart;
}
if ($wallet->balance > 0) {
$wallet->alert_days_sent = 0;
$wallet->last_alert_date = null;
}
$wallet->save();
$log = SmsSendLog::create([
'receiver' => $receiver,
'body' => $body,
'parts' => $parts,
'price_per_part' => $pricePerPart,
'total_amount' => $costRials,
'provider' => $provider,
'source' => $source,
'is_alert' => $isAlert,
'created_at' => now(),
]);
SmsLedger::create([
'type' => $isAlert ? self::TYPE_ALERT_DEBIT : self::TYPE_DEBIT,
'amount' => -$costRials,
'balance_after' => $balanceAfter,
'price_per_part' => $pricePerPart,
'parts' => $parts,
'sms_send_log_id' => $log->id,
'note' => $isAlert ? 'هشدار موجودی پیامک' : 'مصرف ارسال (superadmin)',
'created_at' => now(),
]);
});
}
/**
* @return array{log: SmsSendLog, wallet: SmsWallet}
*/
public function debitSuccessfulSms(
string $receiver,
string $body,
int $parts,
string $provider,
?string $source = null,
bool $isAlert = false
): array {
return DB::transaction(function () use ($receiver, $body, $parts, $provider, $source, $isAlert) {
$wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [
'balance' => 0,
'price_per_part' => 0,
'is_blocked' => false,
'alert_days_sent' => 0,
]);
$pricePerPart = (int) $wallet->price_per_part;
$totalAmount = $parts * $pricePerPart;
$wallet->balance -= $totalAmount;
$wallet->save();
$log = SmsSendLog::create([
'receiver' => $receiver,
'body' => $body,
'parts' => $parts,
'price_per_part' => $pricePerPart,
'total_amount' => $totalAmount,
'provider' => $provider,
'source' => $source,
'is_alert' => $isAlert,
'created_at' => now(),
]);
SmsLedger::create([
'type' => $isAlert ? self::TYPE_ALERT_DEBIT : self::TYPE_DEBIT,
'amount' => -$totalAmount,
'balance_after' => $wallet->balance,
'price_per_part' => $pricePerPart,
'parts' => $parts,
'sms_send_log_id' => $log->id,
'note' => $isAlert ? 'هشدار موجودی پیامک' : null,
'created_at' => now(),
]);
return ['log' => $log, 'wallet' => $wallet->fresh()];
});
}
/**
* اطلاعات واریز جهت شارژ سامانه پیامکی؛ اگر تنظیمی ذخیره نشده باشد، مقدار پیش‌فرض برمی‌گردد.
*
* @return array{card_number: string, sheba: string, account_holder: string, note: string}
*/
public static function getDepositInfo(): array
{
$values = [];
try {
$values = Setting::whereIn('key', array_values(self::DEPOSIT_SETTING_KEYS))
->pluck('value', 'key')
->toArray();
} catch (\Throwable) {
$values = [];
}
$info = [];
foreach (self::DEPOSIT_SETTING_KEYS as $field => $key) {
$stored = $values[$key] ?? null;
$info[$field] = ($stored === null || trim((string) $stored) === '')
? self::DEPOSIT_DEFAULTS[$field]
: (string) $stored;
}
return $info;
}
/**
* ذخیره اطلاعات واریز در تنظیمات. فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند.
*
* @param array<string, string|null> $data
* @return array{card_number: string, sheba: string, account_holder: string, note: string}
*/
public static function setDepositInfo(array $data): array
{
foreach (self::DEPOSIT_SETTING_KEYS as $field => $key) {
if (! array_key_exists($field, $data)) {
continue;
}
$setting = Setting::firstOrNew(['key' => $key]);
$incoming = trim((string) ($data[$field] ?? ''));
if ($incoming === '') {
if ($setting->exists && trim((string) $setting->value) !== '') {
continue;
}
$incoming = self::DEPOSIT_DEFAULTS[$field];
}
$setting->value = $incoming;
if ($setting->title_fa === null || $setting->title_fa === '') {
$setting->title_fa = self::depositFieldTitle($field);
}
if (! $setting->exists) {
$setting->is_active = 1;
$setting->can_edit = 0;
}
$setting->save();
}
return self::getDepositInfo();
}
private static function depositFieldTitle(string $field): string
{
return match ($field) {
'card_number' => 'شماره کارت شارژ پیامک',
'sheba' => 'شماره شبا شارژ پیامک',
'account_holder' => 'نام صاحب حساب شارژ پیامک',
'note' => 'متن راهنمای شارژ پیامک',
default => $field,
};
}
public static function isSmsPanelSuperAdmin(?object $user): bool
{
if (! $user) {
return false;
}
return self::normalizeMobile((string) ($user->mobile ?? '')) === self::SUPER_ADMIN_MOBILE;
}
public static function normalizeMobile(string $mobile): string
{
$digits = preg_replace('/\D/', '', $mobile) ?? '';
if (str_starts_with($digits, '98') && strlen($digits) === 12) {
$digits = '0'.substr($digits, 2);
} elseif (strlen($digits) === 10 && str_starts_with($digits, '9')) {
$digits = '0'.$digits;
}
return $digits;
}
}