diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php
index 413f8213ef..adc3500aca 100644
--- a/app/Http/Controllers/PaymentController.php
+++ b/app/Http/Controllers/PaymentController.php
@@ -14,7 +14,6 @@ namespace App\Http\Controllers;
use App\Events\Payment\PaymentWasUpdated;
use App\Factory\PaymentFactory;
use App\Filters\PaymentFilters;
-use App\Http\Requests\Payment\ActionPaymentRequest;
use App\Http\Requests\Payment\CreatePaymentRequest;
use App\Http\Requests\Payment\DestroyPaymentRequest;
use App\Http\Requests\Payment\EditPaymentRequest;
@@ -24,14 +23,12 @@ use App\Http\Requests\Payment\StorePaymentRequest;
use App\Http\Requests\Payment\UpdatePaymentRequest;
use App\Http\Requests\Payment\UploadPaymentRequest;
use App\Models\Account;
-use App\Models\Invoice;
use App\Models\Payment;
use App\Repositories\PaymentRepository;
use App\Transformers\PaymentTransformer;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\SavesDocuments;
-use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
diff --git a/app/Jobs/Payment/EmailPayment.php b/app/Jobs/Payment/EmailPayment.php
index edcdf83a4c..1ec9ecce8b 100644
--- a/app/Jobs/Payment/EmailPayment.php
+++ b/app/Jobs/Payment/EmailPayment.php
@@ -31,14 +31,8 @@ class EmailPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public $payment;
-
public $email_builder;
- private $contact;
-
- private $company;
-
public $settings;
/**
@@ -49,11 +43,8 @@ class EmailPayment implements ShouldQueue
* @param $contact
* @param $company
*/
- public function __construct(Payment $payment, Company $company, ?ClientContact $contact)
+ public function __construct(public Payment $payment, private Company $company, private ?ClientContact $contact)
{
- $this->payment = $payment;
- $this->contact = $contact;
- $this->company = $company;
$this->settings = $payment->client->getMergedSettings();
}
diff --git a/app/Jobs/Payment/EmailRefundPayment.php b/app/Jobs/Payment/EmailRefundPayment.php
index a6e5de75b2..5563c6ee8a 100644
--- a/app/Jobs/Payment/EmailRefundPayment.php
+++ b/app/Jobs/Payment/EmailRefundPayment.php
@@ -32,14 +32,8 @@ class EmailRefundPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public $payment;
-
public $email_builder;
- private $contact;
-
- private $company;
-
public $settings;
/**
@@ -50,11 +44,8 @@ class EmailRefundPayment implements ShouldQueue
* @param $contact
* @param $company
*/
- public function __construct(Payment $payment, Company $company, ClientContact $contact)
+ public function __construct(public Payment $payment, private Company $company, private ?ClientContact $contact)
{
- $this->payment = $payment;
- $this->contact = $contact;
- $this->company = $company;
$this->settings = $payment->client->getMergedSettings();
}
@@ -84,7 +75,9 @@ class EmailRefundPayment implements ShouldQueue
$template_data['body'] = ctrans('texts.refunded_payment').' $payment.refunded
$invoices';
$template_data['subject'] = ctrans('texts.refunded_payment');
- $email_builder = (new PaymentEmailEngine($this->payment, $this->contact, $template_data))->build();
+ $email_builder = new PaymentEmailEngine($this->payment, $this->contact, $template_data);
+ $email_builder->is_refund = true;
+ $email_builder->build();
$invitation = null;
diff --git a/app/Mail/DownloadDocuments.php b/app/Mail/DownloadDocuments.php
index 70508f3a88..54e2ca0513 100644
--- a/app/Mail/DownloadDocuments.php
+++ b/app/Mail/DownloadDocuments.php
@@ -12,24 +12,14 @@
namespace App\Mail;
use App\Models\Company;
-use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
-use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\App;
class DownloadDocuments extends Mailable
{
- // use Queueable, SerializesModels;
- public $file_path;
-
- public $company;
-
- public function __construct($file_path, Company $company)
+ public function __construct(public string $file_path, public Company $company)
{
- $this->file_path = $file_path;
-
- $this->company = $company;
}
/**
diff --git a/app/Mail/Engine/PaymentEmailEngine.php b/app/Mail/Engine/PaymentEmailEngine.php
index 0e890e2165..9b5c4554a7 100644
--- a/app/Mail/Engine/PaymentEmailEngine.php
+++ b/app/Mail/Engine/PaymentEmailEngine.php
@@ -15,12 +15,14 @@ use App\Utils\Ninja;
use App\Utils\Number;
use App\Utils\Helpers;
use App\Models\Account;
+use App\Models\Payment;
use App\Utils\Traits\MakesDates;
use App\Jobs\Entity\CreateRawPdf;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Storage;
use App\DataMapper\EmailTemplateDefaults;
+use App\Services\Template\TemplateAction;
class PaymentEmailEngine extends BaseEmailEngine
{
@@ -44,6 +46,8 @@ class PaymentEmailEngine extends BaseEmailEngine
private $payment_template_subject;
+ public bool $is_refund = false;
+
public function __construct($payment, $contact, $template_data = null)
{
$this->payment = $payment;
@@ -91,10 +95,53 @@ class PaymentEmailEngine extends BaseEmailEngine
->setViewText('');
if ($this->client->getSetting('pdf_email_attachment') !== false && $this->company->account->hasFeature(Account::FEATURE_PDF_ATTACHMENT)) {
- $this->payment->invoices->each(function ($invoice) {
- $pdf = ((new CreateRawPdf($invoice->invitations->first(), $invoice->company->db))->handle());
- $this->setAttachments([['file' => base64_encode($pdf), 'name' => $invoice->numberFormatter().'.pdf']]);
+ $template_in_use = false;
+
+ if($this->is_refund && strlen($this->payment->client->getSetting('payment_refund_design_id')) > 2) {
+ $pdf = (new TemplateAction(
+ [$this->payment->hashed_id],
+ $this->payment->client->getSetting('payment_refund_design_id'),
+ Payment::class,
+ $this->payment->user_id,
+ $this->payment->company,
+ $this->payment->company->db,
+ 'nohash',
+ false
+ ))->handle();
+
+ $file_name = ctrans('texts.payment_refund_receipt', ['number' => $this->payment->number ]) . '.pdf';
+ $file_name = str_replace(' ', '_', $file_name);
+ $this->setAttachments([['file' => base64_encode($pdf), 'name' => $file_name]]);
+ $template_in_use = true;
+
+ } elseif(!$this->is_refund && strlen($this->payment->client->getSetting('payment_receipt_design_id')) > 2) {
+ $pdf = (new TemplateAction(
+ [$this->payment->hashed_id],
+ $this->payment->client->getSetting('payment_receipt_design_id'),
+ Payment::class,
+ $this->payment->user_id,
+ $this->payment->company,
+ $this->payment->company->db,
+ 'nohash',
+ false
+ ))->handle();
+
+ $file_name = ctrans('texts.payment_receipt', ['number' => $this->payment->number ]) . '.pdf';
+ $file_name = str_replace(' ', '_', $file_name);
+ $this->setAttachments([['file' => base64_encode($pdf), 'name' => $file_name]]);
+ $template_in_use = true;
+
+ }
+
+ $this->payment->invoices->each(function ($invoice) use($template_in_use){
+
+ if(!$template_in_use)
+ {
+ $pdf = ((new CreateRawPdf($invoice->invitations->first(), $invoice->company->db))->handle());
+ $file_name = $invoice->numberFormatter().'.pdf';
+ $this->setAttachments([['file' => base64_encode($pdf), 'name' => $file_name]]);
+ }
//attach invoice documents also to payments
if ($this->client->getSetting('document_email_attachment') !== false) {
diff --git a/app/Models/Payment.php b/app/Models/Payment.php
index 3773e8237d..7898f1b41d 100644
--- a/app/Models/Payment.php
+++ b/app/Models/Payment.php
@@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $id
* @property int $company_id
* @property int $client_id
+ * @property int $category_id
* @property int|null $project_id
* @property int|null $vendor_id
* @property int|null $user_id
@@ -58,6 +59,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $exchange_currency_id
* @property \App\Models\Paymentable $paymentable
* @property object|null $meta
+ * @property object|null $refund_meta
* @property string|null $custom_value1
* @property string|null $custom_value2
* @property string|null $custom_value3
@@ -151,12 +153,12 @@ class Payment extends BaseModel
'number',
'exchange_currency_id',
'exchange_rate',
- // 'is_manual',
'private_notes',
'custom_value1',
'custom_value2',
'custom_value3',
'custom_value4',
+ 'category_id',
];
protected $casts = [
@@ -167,6 +169,7 @@ class Payment extends BaseModel
'deleted_at' => 'timestamp',
'is_deleted' => 'bool',
'meta' => 'object',
+ 'refund_meta' => 'array',
];
protected $with = [
@@ -436,11 +439,6 @@ class Payment extends BaseModel
public function getLink() :string
{
- // if (Ninja::isHosted()) {
- // $domain = isset($this->company->portal_domain) ? $this->company->portal_domain : $this->company->domain();
- // } else {
- // $domain = config('ninja.app_url');
- // }
if (Ninja::isHosted()) {
$domain = $this->company->domain();
@@ -476,4 +474,11 @@ class Payment extends BaseModel
return $use_react_url ? config('ninja.react_url')."/#/payments/{$this->hashed_id}/edit" : config('ninja.app_url');
}
+ public function setRefundMeta(array $data)
+ {
+ $tmp_meta = $this->refund_meta ?? [];
+ $tmp_meta[] = $data;
+
+ $this->refund_meta = $tmp_meta;
+ }
}
diff --git a/app/Services/Email/AdminEmail.php b/app/Services/Email/AdminEmail.php
new file mode 100644
index 0000000000..abf1771d8c
--- /dev/null
+++ b/app/Services/Email/AdminEmail.php
@@ -0,0 +1,671 @@
+company->db);
+
+ $this->setOverride()
+ ->buildMailable();
+
+ if ($this->preFlightChecksFail()) {
+ return;
+ }
+
+ $this->email();
+
+ }
+
+ /**
+ * Sets the override flag
+ *
+ * @return self
+ */
+ public function setOverride(): self
+ {
+ $this->override = $this->email_object->override;
+
+ return $this;
+ }
+
+ /**
+ * Populates the mailable
+ *
+ * @return self
+ */
+ public function buildMailable(): self
+ {
+ $this->mailable = new AdminEmailMailable($this->email_object);
+
+ return $this;
+ }
+
+ /**
+ * Attempts to send the email
+ *
+ * @return void
+ */
+ public function email()
+ {
+ $this->setMailDriver();
+
+ /* Init the mailer*/
+ $mailer = Mail::mailer($this->mailer);
+
+ /* Additional configuration if using a client third party mailer */
+ if ($this->client_postmark_secret) {
+ $mailer->postmark_config($this->client_postmark_secret);
+ }
+
+ if ($this->client_mailgun_secret) {
+ $mailer->mailgun_config($this->client_mailgun_secret, $this->client_mailgun_domain, $this->client_mailgun_endpoint);
+ }
+
+ /* Attempt the send! */
+ try {
+ nlog("Using mailer => ". $this->mailer. " ". now()->toDateTimeString());
+
+ $mailer->send($this->mailable);
+
+ Cache::increment("email_quota".$this->company->account->key);
+
+ LightLogs::create(new EmailSuccess($this->company->company_key))
+ ->send();
+
+ } catch(\Symfony\Component\Mime\Exception\RfcComplianceException $e) {
+ nlog("Mailer failed with a Logic Exception {$e->getMessage()}");
+ $this->fail();
+ $this->cleanUpMailers();
+ $this->logMailError($e->getMessage(), $this->company->clients()->first());
+ return;
+ } catch(\Symfony\Component\Mime\Exception\LogicException $e) {
+ nlog("Mailer failed with a Logic Exception {$e->getMessage()}");
+ $this->fail();
+ $this->cleanUpMailers();
+ $this->logMailError($e->getMessage(), $this->company->clients()->first());
+ return;
+ } catch (\Exception | \RuntimeException | \Google\Service\Exception $e) {
+ nlog("Mailer failed with {$e->getMessage()}");
+ $message = $e->getMessage();
+
+ if (stripos($e->getMessage(), 'code 406') || stripos($e->getMessage(), 'code 300') || stripos($e->getMessage(), 'code 413')) {
+ $message = "Either Attachment too large, or recipient has been suppressed.";
+
+ $this->fail();
+ $this->logMailError($e->getMessage(), $this->company->clients()->first());
+ $this->cleanUpMailers();
+
+ return;
+ }
+
+ /**
+ * Post mark buries the proper message in a a guzzle response
+ * this merges a text string with a json object
+ * need to harvest the ->Message property using the following
+ */
+ if ($e instanceof ClientException) { //postmark specific failure
+ $response = $e->getResponse();
+ $message_body = json_decode($response->getBody()->getContents());
+
+ if ($message_body && property_exists($message_body, 'Message')) {
+ $message = $message_body->Message;
+ nlog($message);
+ }
+
+ $this->fail();
+ $this->cleanUpMailers();
+ return;
+ }
+
+ //only report once, not on all tries
+ if ($this->attempts() == $this->tries) {
+ /* If the is an entity attached to the message send a failure mailer */
+ $this->entityEmailFailed($message);
+
+ /* Don't send postmark failures to Sentry */
+ if (Ninja::isHosted() && (!$e instanceof ClientException)) {
+ app('sentry')->captureException($e);
+ }
+ }
+
+ sleep(rand(0, 3));
+
+ $this->release($this->backoff()[$this->attempts()-1]);
+
+ $message = null;
+ }
+
+ $this->cleanUpMailers();
+ }
+
+ /**
+ * On the hosted platform we scan all outbound email for
+ * spam. This sequence processes the filters we use on all
+ * emails.
+ *
+ * @return bool
+ */
+ public function preFlightChecksFail(): bool
+ {
+ /* Always send if disabled */
+ if($this->override) {
+ return false;
+ }
+
+ /* If we are migrating data we don't want to fire any emails */
+ if ($this->company->is_disabled) {
+ return true;
+ }
+
+ if (Ninja::isSelfHost()) {
+ return false;
+ }
+
+ /* To handle spam users we drop all emails from flagged accounts */
+ if ($this->company->account && $this->company->account->is_flagged) {
+ return true;
+ }
+
+ /* On the hosted platform we set default contacts a @example.com email address - we shouldn't send emails to these types of addresses */
+ if ($this->hasInValidEmails()) {
+ return true;
+ }
+
+ /* GMail users are uncapped */
+ if (in_array($this->email_object->settings->email_sending_method, ['gmail', 'office365', 'client_postmark', 'client_mailgun'])) {
+ return false;
+ }
+
+ /* On the hosted platform, if the user is over the email quotas, we do not send the email. */
+ if ($this->company->account && $this->company->account->emailQuotaExceeded()) {
+ return true;
+ }
+
+ /* If the account is verified, we allow emails to flow */
+ if ($this->company->account && $this->company->account->is_verified_account) {
+ //11-01-2022
+
+ /* Continue to analyse verified accounts in case they later start sending poor quality emails*/
+ // if(class_exists(\Modules\Admin\Jobs\Account\EmailQuality::class))
+ // (new \Modules\Admin\Jobs\Account\EmailQuality($this->nmo, $this->company))->run();
+
+ return false;
+ }
+
+ /* On the hosted platform if the user has not verified their account we fail here - but still check what they are trying to send! */
+ if ($this->company->account && !$this->company->account->account_sms_verified) {
+ if (class_exists(\Modules\Admin\Jobs\Account\EmailFilter::class)) {
+ return (new \Modules\Admin\Jobs\Account\EmailFilter($this->email_object, $this->company))->run();
+ }
+
+ return true;
+ }
+
+ /* On the hosted platform we actively scan all outbound emails to ensure outbound email quality remains high */
+ if (class_exists(\Modules\Admin\Jobs\Account\EmailFilter::class)) {
+ return (new \Modules\Admin\Jobs\Account\EmailFilter($this->email_object, $this->company))->run();
+ }
+
+ return false;
+ }
+
+ /**
+ * hasInValidEmails
+ *
+ * @return bool
+ */
+ private function hasInValidEmails(): bool
+ {
+ foreach ($this->email_object->to as $address_object) {
+ if (strpos($address_object->address, '@example.com') !== false) {
+ return true;
+ }
+
+ if (!str_contains($address_object->address, "@")) {
+ return true;
+ }
+
+ if ($address_object->address == " ") {
+ return true;
+ }
+ }
+
+
+ return false;
+ }
+
+ /**
+ * Sets the mail driver to use and applies any specific configuration
+ * the the mailable
+ */
+ private function setMailDriver(): self
+ {
+ switch ($this->email_object->settings->email_sending_method) {
+ case 'default':
+ $this->mailer = config('mail.default');
+ break;
+ case 'gmail':
+ $this->mailer = 'gmail';
+ $this->setGmailMailer();
+ return $this;
+ case 'office365':
+ case 'microsoft':
+ $this->mailer = 'office365';
+ $this->setOfficeMailer();
+ return $this;
+ case 'client_postmark':
+ $this->mailer = 'postmark';
+ $this->setPostmarkMailer();
+ return $this;
+ case 'client_mailgun':
+ $this->mailer = 'mailgun';
+ $this->setMailgunMailer();
+ return $this;
+
+ default:
+ $this->mailer = config('mail.default');
+ return $this;
+ }
+
+ if (Ninja::isSelfHost()) {
+ $this->setSelfHostMultiMailer();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows configuration of multiple mailers
+ * per company for use by self hosted users
+ */
+ private function setSelfHostMultiMailer(): void
+ {
+ if (env($this->company->id . '_MAIL_HOST')) {
+ config([
+ 'mail.mailers.smtp' => [
+ 'transport' => 'smtp',
+ 'host' => env($this->company->id . '_MAIL_HOST'),
+ 'port' => env($this->company->id . '_MAIL_PORT'),
+ 'username' => env($this->company->id . '_MAIL_USERNAME'),
+ 'password' => env($this->company->id . '_MAIL_PASSWORD'),
+ ],
+ ]);
+
+ if (env($this->company->id . '_MAIL_FROM_ADDRESS')) {
+ $this->mailable
+ ->from(env($this->company->id . '_MAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')), env($this->company->id . '_MAIL_FROM_NAME', env('MAIL_FROM_NAME')));
+ }
+ }
+ }
+
+
+ /**
+ * Ensure we discard any data that is not required
+ *
+ * @return void
+ */
+ private function cleanUpMailers(): void
+ {
+ $this->client_postmark_secret = null;
+
+ $this->client_mailgun_secret = null;
+
+ $this->client_mailgun_domain = null;
+
+ $this->client_mailgun_endpoint = null;
+
+ //always dump the drivers to prevent reuse
+ app('mail.manager')->forgetMailers();
+ }
+
+
+ /**
+ * Check to ensure no cross account
+ * emails can be sent.
+ *
+ * @param User $user
+ */
+ private function checkValidSendingUser($user)
+ {
+ /* Always ensure the user is set on the correct account */
+ if ($user->account_id != $this->company->account_id) {
+ $this->email_object->settings->email_sending_method = 'default';
+
+ return $this->setMailDriver();
+ }
+ }
+
+ /**
+ * Resolves the sending user
+ * when configuring the Mailer
+ * on behalf of the client
+ *
+ * @return User $user
+ */
+ private function resolveSendingUser(): ?User
+ {
+ $sending_user = $this->email_object->settings->gmail_sending_user_id;
+
+ if ($sending_user == "0") {
+ $user = $this->company->owner();
+ } else {
+ $user = User::find($this->decodePrimaryKey($sending_user));
+ }
+
+ return $user;
+ }
+ /**
+ * Configures Mailgun using client supplied secret
+ * as the Mailer
+ */
+ private function setMailgunMailer()
+ {
+ if (strlen($this->email_object->settings->mailgun_secret) > 2 && strlen($this->email_object->settings->mailgun_domain) > 2) {
+ $this->client_mailgun_secret = $this->email_object->settings->mailgun_secret;
+ $this->client_mailgun_domain = $this->email_object->settings->mailgun_domain;
+ $this->client_mailgun_endpoint = $this->email_object->settings->mailgun_endpoint;
+
+ } else {
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ $user = $this->resolveSendingUser();
+
+ $sending_email = (isset($this->email_object->settings->custom_sending_email) && stripos($this->email_object->settings->custom_sending_email, "@")) ? $this->email_object->settings->custom_sending_email : $user->email;
+ $sending_user = (isset($this->email_object->settings->email_from_name) && strlen($this->email_object->settings->email_from_name) > 2) ? $this->email_object->settings->email_from_name : $user->name();
+
+ $this->mailable
+ ->from($sending_email, $sending_user);
+ }
+
+ /**
+ * Configures Postmark using client supplied secret
+ * as the Mailer
+ */
+ private function setPostmarkMailer()
+ {
+ if (strlen($this->email_object->settings->postmark_secret) > 2) {
+ $this->client_postmark_secret = $this->email_object->settings->postmark_secret;
+ } else {
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ $user = $this->resolveSendingUser();
+
+ $sending_email = (isset($this->email_object->settings->custom_sending_email) && stripos($this->email_object->settings->custom_sending_email, "@")) ? $this->email_object->settings->custom_sending_email : $user->email;
+ $sending_user = (isset($this->email_object->settings->email_from_name) && strlen($this->email_object->settings->email_from_name) > 2) ? $this->email_object->settings->email_from_name : $user->name();
+
+ $this->mailable
+ ->from($sending_email, $sending_user);
+ }
+
+ /**
+ * Configures Microsoft via Oauth
+ * as the Mailer
+ */
+ private function setOfficeMailer()
+ {
+ $user = $this->resolveSendingUser();
+
+ $this->checkValidSendingUser($user);
+
+ nlog("Sending via {$user->name()}");
+
+ $token = $this->refreshOfficeToken($user);
+
+ if ($token) {
+ $user->oauth_user_token = $token;
+ $user->save();
+ } else {
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ $this->mailable
+ ->from($user->email, $user->name())
+ ->withSymfonyMessage(function ($message) use ($token) {
+ $message->getHeaders()->addTextHeader('gmailtoken', $token);
+ });
+ }
+
+ /**
+ * Configures GMail via Oauth
+ * as the Mailer
+ */
+ private function setGmailMailer()
+ {
+ $user = $this->resolveSendingUser();
+
+ $this->checkValidSendingUser($user);
+
+ nlog("Sending via {$user->name()}");
+
+ $google = (new Google())->init();
+
+ try {
+ if ($google->getClient()->isAccessTokenExpired()) {
+ $google->refreshToken($user);
+ $user = $user->fresh();
+ }
+
+ $google->getClient()->setAccessToken(json_encode($user->oauth_user_token));
+ } catch(\Exception $e) {
+ $this->logMailError('Gmail Token Invalid', $this->company->clients()->first());
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ /**
+ * If the user doesn't have a valid token, notify them
+ */
+
+ if (!$user->oauth_user_token) {
+ $this->company->account->gmailCredentialNotification();
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ /*
+ * Now that our token is refreshed and valid we can boot the
+ * mail driver at runtime and also set the token which will persist
+ * just for this request.
+ */
+
+ $token = $user->oauth_user_token->access_token;
+
+ if (!$token) {
+ $this->company->account->gmailCredentialNotification();
+ $this->email_object->settings->email_sending_method = 'default';
+ return $this->setMailDriver();
+ }
+
+ $this->mailable
+ ->from($user->email, $user->name())
+ ->withSymfonyMessage(function ($message) use ($token) {
+ $message->getHeaders()->addTextHeader('gmailtoken', $token);
+ });
+ }
+
+ /**
+ * Logs any errors to the SystemLog
+ *
+ * @param string $errors
+ * @param null | \App\Models\Client $recipient_object
+ * @return void
+ */
+ private function logMailError($errors, $recipient_object) :void
+ {
+ (new SystemLogger(
+ $errors,
+ SystemLog::CATEGORY_MAIL,
+ SystemLog::EVENT_MAIL_SEND,
+ SystemLog::TYPE_FAILURE,
+ $recipient_object,
+ $this->company
+ ))->handle();
+
+ $job_failure = new EmailFailure($this->company->company_key);
+ $job_failure->string_metric5 = 'failed_email';
+ $job_failure->string_metric6 = substr($errors, 0, 150);
+
+ LightLogs::create($job_failure)
+ ->send();
+
+ $job_failure = null;
+ }
+
+ /**
+ * Attempts to refresh the Microsoft refreshToken
+ *
+ * @param \App\Models\User $user
+ * @return mixed
+ */
+ private function refreshOfficeToken(User $user): mixed
+ {
+ $expiry = $user->oauth_user_token_expiry ?: now()->subDay();
+
+ if ($expiry->lt(now())) {
+ $guzzle = new \GuzzleHttp\Client();
+ $url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
+
+ $token = json_decode($guzzle->post($url, [
+ 'form_params' => [
+ 'client_id' => config('ninja.o365.client_id') ,
+ 'client_secret' => config('ninja.o365.client_secret') ,
+ 'scope' => 'email Mail.Send offline_access profile User.Read openid',
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $user->oauth_user_refresh_token
+ ],
+ ])->getBody()->getContents());
+
+ if ($token) {
+ $user->oauth_user_refresh_token = property_exists($token, 'refresh_token') ? $token->refresh_token : $user->oauth_user_refresh_token;
+ $user->oauth_user_token = $token->access_token;
+ $user->oauth_user_token_expiry = now()->addSeconds($token->expires_in);
+ $user->save();
+
+ return $token->access_token;
+ }
+
+ return false;
+ }
+
+ return $user->oauth_user_token;
+ }
+
+ /**
+ * Entity notification when an email fails to send
+ *
+ * @param string $message
+ * @return void
+ */
+ private function entityEmailFailed($message): void
+ {
+ $class = get_class($this->email_object->entity);
+
+ switch ($class) {
+ case Invoice::class:
+ event(new InvoiceWasEmailedAndFailed($this->email_object->invitation, $this->company, $message, $this->email_object->html_template, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
+ break;
+ case Payment::class:
+ event(new PaymentWasEmailedAndFailed($this->email_object->entity, $this->company, $message, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
+ break;
+ default:
+ # code...
+ break;
+ }
+
+ if ($this->email_object->client) {
+ $this->logMailError($message, $this->email_object->client);
+ }
+ }
+
+
+ public function failed($exception = null)
+ {
+ if ($exception) {
+ nlog($exception->getMessage());
+ }
+
+ config(['queue.failed.driver' => null]);
+ }
+}
diff --git a/app/Services/Email/AdminEmailMailable.php b/app/Services/Email/AdminEmailMailable.php
new file mode 100644
index 0000000000..94cfc53a83
--- /dev/null
+++ b/app/Services/Email/AdminEmailMailable.php
@@ -0,0 +1,106 @@
+", "", $this->email_object->subject),
+ tags: [$this->email_object->company_key],
+ replyTo: $this->email_object->reply_to,
+ from: $this->email_object->from,
+ to: $this->email_object->to,
+ bcc: $this->email_object->bcc,
+ cc: $this->email_object->cc,
+ );
+ }
+
+ /**
+ * Get the message content definition.
+ *
+ * @return \Illuminate\Mail\Mailables\Content
+ */
+ public function content()
+ {
+
+ return new Content(
+ view: 'email.admin.generic',
+ text: 'email.admin.generic_text',
+ with: [
+ 'title' => $this->email_object->subject,
+ 'message' => $this->email_object->body,
+ 'url' => $this->email_object->url ?? null,
+ 'button' => $this->email_object->button ?? null,
+ 'signature' => $this->email_object->company->owner()->signature,
+ 'logo' => $this->email_object->company->present()->logo(),
+ 'settings' => $this->email_object->settings,
+ 'whitelabel' => $this->email_object->company->account->isPaid() ? true : false,
+ ]
+ );
+ }
+
+ /**
+ * Get the attachments for the message.
+ *
+ * @return array
+ */
+ public function attachments()
+ {
+ $attachments = [];
+
+ $attachments = collect($this->email_object->attachments)->map(function ($file) {
+ return Attachment::fromData(fn () => base64_decode($file['file']), $file['name']);
+ });
+
+ return $attachments->toArray();
+ }
+
+ /**
+ * Get the message headers.
+ *
+ * @return \Illuminate\Mail\Mailables\Headers
+ */
+ public function headers()
+ {
+ return new Headers(
+ messageId: null,
+ references: [],
+ text: $this->email_object->headers,
+ );
+ }
+}
diff --git a/app/Services/Email/Email.php b/app/Services/Email/Email.php
index cebb8b7b6f..12a0d46e50 100644
--- a/app/Services/Email/Email.php
+++ b/app/Services/Email/Email.php
@@ -75,7 +75,6 @@ class Email implements ShouldQueue
*/
public function backoff()
{
- // return [10, 30, 60, 240];
return [rand(10, 20), rand(30, 45), rand(60, 79), rand(160, 400)];
}
diff --git a/app/Services/Email/EmailObject.php b/app/Services/Email/EmailObject.php
index 4eb7c0b28e..86b57e934c 100644
--- a/app/Services/Email/EmailObject.php
+++ b/app/Services/Email/EmailObject.php
@@ -121,4 +121,8 @@ class EmailObject
public ?string $template = null; //invoice //quote //reminder1
public array $links = [];
+
+ public ?string $button = null;
+
+ public ?string $url = null;
}
diff --git a/app/Services/Payment/RefundPayment.php b/app/Services/Payment/RefundPayment.php
index 007f1df6ef..c3e5c44201 100644
--- a/app/Services/Payment/RefundPayment.php
+++ b/app/Services/Payment/RefundPayment.php
@@ -23,33 +23,15 @@ use stdClass;
class RefundPayment
{
- public $payment;
- public $refund_data;
-
- private $credit_note;
-
- private $total_refund;
-
- private $gateway_refund_status;
-
- private $activity_repository;
+ private float $total_refund = 0;
private bool $refund_failed = false;
private string $refund_failed_message = '';
- public function __construct($payment, $refund_data)
+ public function __construct(public Payment $payment, public array $refund_data)
{
- $this->payment = $payment;
-
- $this->refund_data = $refund_data;
-
- $this->total_refund = 0;
-
- $this->gateway_refund_status = false;
-
- $this->activity_repository = new ActivityRepository();
}
public function run()
@@ -135,6 +117,8 @@ class RefundPayment
$this->payment->refunded += $this->total_refund;
}
+ $this->payment->setRefundMeta($this->refund_data);
+
return $this;
}
diff --git a/app/Services/Template/TemplateAction.php b/app/Services/Template/TemplateAction.php
index 80f0a3351e..c1a8ebbd10 100644
--- a/app/Services/Template/TemplateAction.php
+++ b/app/Services/Template/TemplateAction.php
@@ -12,6 +12,7 @@
namespace App\Services\Template;
use App\Models\Task;
+use App\Models\User;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Credit;
@@ -28,6 +29,9 @@ use App\Models\PurchaseOrder;
use Illuminate\Bus\Queueable;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringInvoice;
+use App\Services\Email\AdminEmail;
+use App\Services\Email\EmailObject;
+use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Queue\InteractsWithQueue;
@@ -47,7 +51,7 @@ class TemplateAction implements ShouldQueue
*
* @param array $ids The array of entity IDs
* @param string $template The template id
- * @param Builder | Invoice | Quote | Task | Credit | RecurringInvoice | Project | Expense | Client | Payment | Product | PurchaseOrder | Vendor $entity The entity class name
+ * @param string $entity The entity class name
* @param int $user_id requesting the template
* @param string $db The database name
* @param bool $send_email Determines whether to send an email
@@ -70,7 +74,9 @@ class TemplateAction implements ShouldQueue
*
*/
public function handle()
- {
+ {
+ // nlog("inside template action");
+
MultiDB::setDb($this->db);
$key = $this->resolveEntityString();
@@ -81,37 +87,62 @@ class TemplateAction implements ShouldQueue
$template_service = new TemplateService($template);
- if($this->entity == Invoice::class) {
- $resource->with('payments', 'client');
- }
+ match($this->entity){
+ Invoice::class => $resource->with('payments', 'client'),
+ Quote::class => $resource->with('client'),
+ Task::class => $resource->with('client'),
+ Credit::class => $resource->with('client'),
+ RecurringInvoice::class => $resource->with('client'),
+ Project::class => $resource->with('client'),
+ Expense::class => $resource->with('client'),
+ Payment::class => $resource->with('invoices', 'client'),
+ };
$result = $resource->withTrashed()
->whereIn('id', $this->transformKeys($this->ids))
->where('company_id', $this->company->id)
->get();
-
if($result->count() <= 1)
$data[$key] = collect($result);
else
$data[$key] = $result;
- $pdf = $template_service->build($data)->getPdf();
+ $ts = $template_service->build($data);
+
+ // nlog($ts->getHtml());
- if($this->send_email)
- $this->sendEmail($pdf);
+ if($this->send_email) {
+ $pdf = $ts->getPdf();
+ $this->sendEmail($pdf, $template);
+ }
else {
-
+ $pdf = $ts->getPdf();
$filename = "templates/{$this->hash}.pdf";
Storage::disk(config('filesystems.default'))->put($filename, $pdf);
-
+ return $pdf;
}
}
- private function sendEmail(mixed $pdf): mixed
+ private function sendEmail(mixed $pdf, Design $template)
{
- //send the email.
- return $pdf;
+ $user = $this->user_id ? User::find($this->user_id) : $this->company->owner();
+
+ $template_name = " [{$template->name}]";
+ $email_object = new EmailObject;
+ $email_object->to = [new Address($user->email, $user->present()->name())];
+ $email_object->attachments = [['file' => base64_encode($pdf), 'name' => ctrans('texts.template') . ".pdf"]];
+ $email_object->company_key = $this->company->company_key;
+ $email_object->company = $this->company;
+ $email_object->settings = $this->company->settings;
+ $email_object->logo = $this->company->present()->logo();
+ $email_object->whitelabel = $this->company->account->isPaid() ? true : false;
+ $email_object->user_id = $user->id;
+ $email_object->text_body = ctrans('texts.download_report_description') . $template_name;
+ $email_object->body = ctrans('texts.download_report_description') . $template_name;
+ $email_object->subject = ctrans('texts.download_report_description') . $template_name;
+
+ (new AdminEmail($email_object, $this->company))->handle();
}
/**
diff --git a/app/Services/Template/TemplateService.php b/app/Services/Template/TemplateService.php
index 9ee5d97964..4e3a2ace45 100644
--- a/app/Services/Template/TemplateService.php
+++ b/app/Services/Template/TemplateService.php
@@ -16,8 +16,10 @@ use App\Models\Client;
use App\Models\Credit;
use App\Models\Design;
use App\Models\Company;
+use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Project;
+use App\Models\Activity;
use App\Utils\HtmlEngine;
use League\Fractal\Manager;
use App\Models\PurchaseOrder;
@@ -25,6 +27,7 @@ use App\Utils\VendorHtmlEngine;
use App\Utils\PaymentHtmlEngine;
use App\Utils\Traits\MakesDates;
use App\Utils\HostedPDF\NinjaPdf;
+use App\Utils\Traits\Pdf\PdfMaker;
use Twig\Extra\Intl\IntlExtension;
use App\Transformers\TaskTransformer;
use App\Transformers\QuoteTransformer;
@@ -34,7 +37,6 @@ use App\Transformers\InvoiceTransformer;
use App\Transformers\ProjectTransformer;
use App\Transformers\PurchaseOrderTransformer;
use League\Fractal\Serializer\ArraySerializer;
-use App\Utils\Traits\Pdf\PdfMaker;
class TemplateService
{
@@ -470,6 +472,7 @@ class TemplateService
'refunded' => Number::formatMoney($credit->pivot->refunded, $payment->client),
'net' => Number::formatMoney($credit->pivot->amount - $credit->pivot->refunded, $payment->client),
'is_credit' => true,
+ 'date' => $this->translateDate($credit->date, $payment->client->date_format(), $payment->client->locale()),
'created_at' => $this->translateDate($credit->pivot->created_at, $payment->client->date_format(), $payment->client->locale()),
'updated_at' => $this->translateDate($credit->pivot->updated_at, $payment->client->date_format(), $payment->client->locale()),
'timestamp' => $credit->pivot->created_at->timestamp,
@@ -486,6 +489,7 @@ class TemplateService
'refunded' => Number::formatMoney($invoice->pivot->refunded, $payment->client),
'net' => Number::formatMoney($invoice->pivot->amount - $invoice->pivot->refunded, $payment->client),
'is_credit' => false,
+ 'date' => $this->translateDate($invoice->date, $payment->client->date_format(), $payment->client->locale()),
'created_at' => $this->translateDate($invoice->pivot->created_at, $payment->client->date_format(), $payment->client->locale()),
'updated_at' => $this->translateDate($invoice->pivot->updated_at, $payment->client->date_format(), $payment->client->locale()),
'timestamp' => $invoice->pivot->created_at->timestamp,
@@ -514,6 +518,8 @@ class TemplateService
'custom_value2' => $payment->custom_value2 ?? '',
'custom_value3' => $payment->custom_value3 ?? '',
'custom_value4' => $payment->custom_value4 ?? '',
+ 'created_at' => $this->translateDate($payment->created_at, $payment->client->date_format(), $payment->client->locale()),
+ 'updated_at' => $this->translateDate($payment->updated_at, $payment->client->date_format(), $payment->client->locale()),
'client' => [
'name' => $payment->client->present()->name(),
'balance' => $payment->client->balance,
@@ -521,15 +527,64 @@ class TemplateService
'credit_balance' => $payment->client->credit_balance,
],
'paymentables' => $pivot,
+ 'refund_activity' => $this->getPaymentRefundActivity($payment),
];
-
+
+ nlog($this->getPaymentRefundActivity($payment));
+
return $data;
+ }
+ /**
+ * [
+ "id" => 12,
+ "date" => "2023-10-08",
+ "invoices" => [
+ [
+ "amount" => 1,
+ "invoice_id" => 23,
+ "id" => null,
+ ],
+ ],
+ "q" => "/api/v1/payments/refund",
+ "email_receipt" => "true",
+ "gateway_refund" => false,
+ "send_email" => false,
+ ],
+ *
+ * @param Payment $payment
+ * @return array
+ */
+ private function getPaymentRefundActivity(Payment $payment): array
+ {
+
+ return collect($payment->refund_meta ?? [])
+ ->map(function ($refund) use($payment){
+
+ $date = \Carbon\Carbon::parse($refund['date'])->addSeconds($payment->client->timezone_offset());
+ $date = $this->translateDate($date, $payment->client->date_format(), $payment->client->locale());
+ $entity = ctrans('texts.invoice');
+
+ $map = [];
+
+ foreach($refund['invoices'] as $refunded_invoice) {
+ $invoice = Invoice::withTrashed()->find($refunded_invoice['invoice_id']);
+ $amount = Number::formatMoney($refunded_invoice['amount'], $payment->client);
+ $notes = ctrans('texts.status_partially_refunded_amount', ['amount' => $amount]);
+
+ array_push($map, "{$date} {$entity} #{$invoice->number} {$notes}\n");
+
+ }
+
+ return $map;
+
+ })->flatten()->toArray();
}
+
public function processQuotes($quotes): array
{
$it = new QuoteTransformer();
@@ -587,27 +642,11 @@ class TemplateService
{
$payments = $payments->map(function ($payment) {
- // nlog(microtime(true));
return $this->transformPayment($payment);
})->toArray();
-
+
return $payments;
- // $it = new PaymentTransformer();
- // $it->setDefaultIncludes(['client','invoices','paymentables']);
- // $manager = new Manager();
- // $manager->parseIncludes(['client','invoices','paymentables']);
- // $resource = new \League\Fractal\Resource\Collection($payments, $it, null);
- // $resources = $manager->createData($resource)->toArray();
- // foreach($resources['data'] as $key => $resource) {
-
- // $resources['data'][$key]['client'] = $resource['client']['data'] ?? [];
- // $resources['data'][$key]['client']['contacts'] = $resource['client']['data']['contacts']['data'] ?? [];
- // $resources['data'][$key]['invoices'] = $invoice['invoices']['data'] ?? [];
-
- // }
-
- // return $resources['data'];
}
diff --git a/app/Utils/PaymentHtmlEngine.php b/app/Utils/PaymentHtmlEngine.php
index e3b0be94c0..eaa8e902e4 100644
--- a/app/Utils/PaymentHtmlEngine.php
+++ b/app/Utils/PaymentHtmlEngine.php
@@ -64,7 +64,15 @@ class PaymentHtmlEngine
$data['$amount'] = &$data['$payment.amount'];
$data['$payment.date'] = ['value' => $this->translateDate($this->payment->date, $this->client->date_format(), $this->client->locale()), 'label' => ctrans('texts.payment_date')];
$data['$transaction_reference'] = ['value' => $this->payment->transaction_reference, 'label' => ctrans('texts.transaction_reference')];
- // $data['$public_notes'] = ['value' => $this->payment->public_notes, 'label' => ctrans('texts.notes')];
+
+ $data['$font_size'] = ['value' => $this->settings->font_size . 'px !important;', 'label' => ''];
+ $data['$font_name'] = ['value' => Helpers::resolveFont($this->settings->primary_font)['name'], 'label' => ''];
+ $data['$font_url'] = ['value' => Helpers::resolveFont($this->settings->primary_font)['url'], 'label' => ''];
+ $data['$secondary_font_name'] = ['value' => Helpers::resolveFont($this->settings->secondary_font)['name'], 'label' => ''];
+ $data['$secondary_font_url'] = ['value' => Helpers::resolveFont($this->settings->secondary_font)['url'], 'label' => ''];
+ $data['$invoiceninja.whitelabel'] = ['value' => 'https://invoicing.co/images/new_logo.png', 'label' => ''];
+ $data['$primary_color'] = ['value' => $this->settings->primary_color, 'label' => ''];
+ $data['$secondary_color'] = ['value' => $this->settings->secondary_color, 'label' => ''];
$data['$payment1'] = ['value' => $this->helpers->formatCustomFieldValue($this->company->custom_fields, 'payment1', $this->payment->custom_value1, $this->client) ?: ' ', 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'payment1')];
$data['$payment2'] = ['value' => $this->helpers->formatCustomFieldValue($this->company->custom_fields, 'payment2', $this->payment->custom_value2, $this->client) ?: ' ', 'label' => $this->helpers->makeCustomField($this->company->custom_fields, 'payment2')];
diff --git a/database/migrations/2023_10_08_092508_add_refund_meta_and_category_to_payments_table.php b/database/migrations/2023_10_08_092508_add_refund_meta_and_category_to_payments_table.php
new file mode 100644
index 0000000000..b738ff1561
--- /dev/null
+++ b/database/migrations/2023_10_08_092508_add_refund_meta_and_category_to_payments_table.php
@@ -0,0 +1,26 @@
+text('refund_meta')->nullable();
+ $table->unsignedInteger('category_id')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ }
+};
diff --git a/lang/en/texts.php b/lang/en/texts.php
index a5bc8f47fe..aa407bc9b0 100644
--- a/lang/en/texts.php
+++ b/lang/en/texts.php
@@ -5161,6 +5161,8 @@ $LANG = array(
'show_document_preview' => 'Show Document Preview',
'cash_accounting' => 'Cash accounting',
'click_or_drop_files_here' => 'Click or drop files here',
+ 'payment_refund_receipt' => 'Payment Refund Receipt # :number',
+ 'payment_receipt' => 'Payment Receipt # :number',
);
return $LANG;