Fixes for tests

This commit is contained in:
David Bomba
2025-08-21 12:22:38 +10:00
parent 577e1abaa7
commit 501b8085f0
7 changed files with 951 additions and 4 deletions

View File

@@ -0,0 +1,258 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Jobs\SES\SESWebhook;
use App\Jobs\PostMark\ProcessPostmarkWebhook;
use App\Libraries\MultiDB;
use App\Services\InboundMail\InboundMail;
use App\Services\InboundMail\InboundMailEngine;
use App\Utils\TempFile;
use Illuminate\Support\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Class SNSController.
*
* Handles Amazon SNS webhook notifications that contain SES email event data.
* SNS acts as an intermediary between SES and your application.
*/
class SNSController extends BaseController
{
public function __construct()
{
}
/**
* Handle SNS webhook notifications
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function webhook(Request $request)
{
try {
// Get the raw request body for SNS signature verification
$payload = $request->getContent();
$headers = $request->headers->all();
Log::info('SNS Webhook received', [
'headers' => $headers,
'payload_size' => strlen($payload)
]);
// Parse the SNS payload
$snsData = json_decode($payload, true);
if (!$snsData) {
Log::error('SNS Webhook: Invalid JSON payload');
return response()->json(['error' => 'Invalid JSON payload'], 400);
}
// Get SNS message type from headers (AWS SNS specific)
$snsMessageType = $headers['x-amz-sns-message-type'][0] ?? null;
if (!$snsMessageType) {
Log::error('SNS Webhook: Missing x-amz-sns-message-type header');
return response()->json(['error' => 'Missing SNS message type'], 400);
}
// Handle SNS subscription confirmation
if ($snsMessageType === 'SubscriptionConfirmation') {
return $this->handleSubscriptionConfirmation($snsData);
}
// Handle SNS notification (contains SES data)
if ($snsMessageType === 'Notification') {
return $this->handleSESNotification($snsData);
}
// Handle unsubscribe confirmation
if ($snsMessageType === 'UnsubscribeConfirmation') {
Log::info('SNS Unsubscribe confirmation received', ['topic_arn' => $snsData['TopicArn'] ?? 'unknown']);
return response()->json(['status' => 'unsubscribe_confirmed']);
}
Log::warning('SNS Webhook: Unknown message type', ['type' => $snsMessageType]);
return response()->json(['error' => 'Unknown message type'], 400);
} catch (\Exception $e) {
Log::error('SNS Webhook: Error processing request', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json(['error' => 'Internal server error'], 500);
}
}
/**
* Handle SNS subscription confirmation
*
* @param array $snsData
* @return \Illuminate\Http\JsonResponse
*/
private function handleSubscriptionConfirmation(array $snsData)
{
$subscribeUrl = $snsData['SubscribeURL'] ?? null;
if (!$subscribeUrl) {
Log::error('SNS Subscription confirmation: Missing SubscribeURL');
return response()->json(['error' => 'Missing SubscribeURL'], 400);
}
Log::info('SNS Subscription confirmation received', [
'topic_arn' => $snsData['TopicArn'] ?? 'unknown',
'subscribe_url' => $subscribeUrl
]);
// You can optionally make an HTTP request to confirm the subscription
// This is required by AWS to complete the SNS subscription setup
try {
$response = file_get_contents($subscribeUrl);
Log::info('SNS Subscription confirmed', ['response' => $response]);
} catch (\Exception $e) {
Log::error('SNS Subscription confirmation failed', ['error' => $e->getMessage()]);
}
return response()->json(['status' => 'subscription_confirmed']);
}
/**
* Handle SES notification from SNS
*
* @param array $snsData
* @return \Illuminate\Http\JsonResponse
*/
private function handleSESNotification(array $snsData)
{
$message = $snsData['Message'] ?? null;
if (!$message) {
Log::error('SNS Notification: Missing Message content');
return response()->json(['error' => 'Missing Message content'], 400);
}
// Parse the SES message (it's JSON encoded as a string)
$sesData = json_decode($message, true);
if (!$sesData) {
Log::error('SNS Notification: Invalid SES message format');
return response()->json(['error' => 'Invalid SES message format'], 400);
}
Log::info('SNS Notification: Processing SES data', [
'notification_type' => $sesData['notificationType'] ?? $sesData['eventType'] ?? 'unknown',
'message_id' => $sesData['mail']['messageId'] ?? 'unknown'
]);
// Extract company key from SES data
$companyKey = $this->extractCompanyKeyFromSES($sesData);
if (!$companyKey) {
Log::warning('SNS Notification: No company key found in SES data', [
'ses_data' => $sesData
]);
return response()->json(['error' => 'No company key found'], 400);
}
// Dispatch the SES webhook job for processing
try {
SESWebhook::dispatch($sesData);
Log::info('SNS Notification: SES webhook job dispatched successfully', [
'company_key' => $companyKey,
'message_id' => $sesData['mail']['messageId'] ?? 'unknown'
]);
return response()->json(['status' => 'webhook_processed']);
} catch (\Exception $e) {
Log::error('SNS Notification: Failed to dispatch SES webhook job', [
'error' => $e->getMessage(),
'company_key' => $companyKey
]);
return response()->json(['error' => 'Failed to process webhook'], 500);
}
}
/**
* Extract company key from SES data
*
* @param array $sesData
* @return string|null
*/
private function extractCompanyKeyFromSES(array $sesData): ?string
{
// Check various possible locations for company key in SES data
// Check mail tags
if (isset($sesData['mail']['tags']['company_key'])) {
Log::info('SNS: Found company key in mail tags', ['value' => $sesData['mail']['tags']['company_key']]);
return $sesData['mail']['tags']['company_key'];
}
// Check custom headers - specifically X-Tag which contains the company key
if (isset($sesData['mail']['headers'])) {
Log::info('SNS: Checking mail headers for X-Tag', [
'headers_count' => count($sesData['mail']['headers']),
'headers' => $sesData['mail']['headers']
]);
foreach ($sesData['mail']['headers'] as $header) {
if (isset($header['name']) && $header['name'] === 'X-Tag' && isset($header['value'])) {
Log::info('SNS: Found X-Tag header', ['value' => $header['value']]);
return $header['value'];
}
}
Log::warning('SNS: X-Tag header not found in mail headers');
}
// Check if company key is in the main SES data
if (isset($sesData['company_key'])) {
Log::info('SNS: Found company key in main SES data', ['value' => $sesData['company_key']]);
return $sesData['company_key'];
}
// Check bounce data
if (isset($sesData['bounce']) && isset($sesData['bounce']['tags']['company_key'])) {
Log::info('SNS: Found company key in bounce tags', ['value' => $sesData['bounce']['tags']['company_key']]);
return $sesData['bounce']['tags']['company_key'];
}
// Check complaint data
if (isset($sesData['complaint']) && isset($sesData['complaint']['tags']['company_key'])) {
Log::info('SNS: Found company key in complaint tags', ['value' => $sesData['complaint']['tags']['company_key']]);
return $sesData['complaint']['tags']['company_key'];
}
// Check delivery data
if (isset($sesData['delivery']) && isset($sesData['delivery']['tags']['company_key'])) {
Log::info('SNS: Found company key in delivery tags', ['value' => $sesData['delivery']['tags']['company_key']]);
return $sesData['delivery']['tags']['company_key'];
}
Log::warning('SNS: No company key found in any location', [
'mail_headers_exists' => isset($sesData['mail']['headers']),
'mail_common_headers_exists' => isset($sesData['mail']['commonHeaders']),
'bounce_exists' => isset($sesData['bounce']),
'complaint_exists' => isset($sesData['complaint']),
'delivery_exists' => isset($sesData['delivery'])
]);
return null;
}
}

661
app/Jobs/SES/SESWebhook.php Normal file
View File

@@ -0,0 +1,661 @@
<?php
namespace App\Jobs\SES;
use App\Models\Company;
use App\Models\SystemLog;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use App\Jobs\Util\SystemLogger;
use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
use Illuminate\Queue\SerializesModels;
use Turbo124\Beacon\Facades\LightLogs;
use App\Models\PurchaseOrderInvitation;
use Illuminate\Queue\InteractsWithQueue;
use App\Models\RecurringInvoiceInvitation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\DataMapper\Analytics\Mail\EmailSpam;
use App\DataMapper\Analytics\Mail\EmailBounce;
use App\Notifications\Ninja\EmailSpamNotification;
use App\Notifications\Ninja\EmailBounceNotification;
class SESWebhook implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $tries = 1;
public $invitation;
private $entity;
private array $default_response = [
'recipients' => '',
'subject' => 'Message not found.',
'entity' => '',
'entity_id' => '',
'events' => [],
'event_type' => 'unknown',
'timestamp' => '',
'message_id' => ''
];
private ?Company $company = null;
/**
* Create a new job instance.
*
*/
public function __construct(private array $request)
{
}
private function getSystemLog(string $message_id): ?SystemLog
{
return SystemLog::query()
->where('company_id', $this->invitation->company_id)
->where('type_id', SystemLog::TYPE_WEBHOOK_RESPONSE)
->where('category_id', SystemLog::CATEGORY_MAIL)
->where('log->MessageID', $message_id)
->orderBy('id', 'desc')
->first();
}
private function updateSystemLog(SystemLog $system_log, array $data): void
{
// Get existing log data or initialize empty array
$existing_log = $system_log->log ?? [];
$existing_history = $existing_log['history'] ?? [];
$existing_events = $existing_history['events'] ?? [];
// Get new event data from the current webhook
$new_event = $this->extractEventData();
// Check if this event type already exists in events array
$event_type = $this->getCurrentEventType();
$event_exists = false;
foreach ($existing_events as $event) {
if (isset($event['event_type']) && $event['event_type'] === $event_type) {
$event_exists = true;
break;
}
}
// Only add new event if this event type doesn't already exist
if (!$event_exists && !empty($new_event)) {
$existing_events[] = $new_event;
}
// Update the history with existing events plus any new event
$updated_history = array_merge($existing_history, [
'events' => $existing_events
]);
// Update the log with existing data plus updated history
$system_log->log = array_merge($existing_log, [
'history' => $updated_history,
'last_updated' => now()->toISOString(),
'last_event_type' => $event_type
]);
$system_log->save();
}
/**
* Get the current event type being processed
*/
private function getCurrentEventType(): string
{
$notification_type = $this->request['eventType'] ?? $this->request['Type'] ?? $this->request['notificationType'] ?? '';
switch ($notification_type) {
case 'Delivery':
case 'Received':
return 'delivery';
case 'Bounce':
return 'bounce';
case 'Complaint':
return 'complaint';
case 'Open':
case 'Rendering Failure':
return 'open';
default:
return 'unknown';
}
}
/**
* Extract company key from SES message tags or metadata
*/
private function extractCompanyKey(): ?string
{
// Check various possible locations for company key
// Check mail tags
if (isset($this->request['mail']['tags']['company_key'])) {
nlog("SESWebhook: Found company key in mail tags: " . $this->request['mail']['tags']['company_key']);
return $this->request['mail']['tags']['company_key'];
}
// Check email headers - specifically X-Tag which contains the company key
if (isset($this->request['mail']['headers'])) {
nlog("SESWebhook: Checking mail headers for X-Tag", [
'headers_count' => count($this->request['mail']['headers']),
'headers' => $this->request['mail']['headers']
]);
foreach ($this->request['mail']['headers'] as $header) {
if (isset($header['name']) && $header['name'] === 'X-Tag' && isset($header['value'])) {
nlog("SESWebhook: Found X-Tag header: " . $header['value']);
return $header['value'];
}
}
nlog("SESWebhook: X-Tag header not found in mail headers");
}
// Check common headers
if (isset($this->request['mail']['commonHeaders']['x-company-key'])) {
nlog("SESWebhook: Found company key in common headers: " . $this->request['mail']['commonHeaders']['x-company-key']);
return $this->request['mail']['commonHeaders']['x-company-key'];
}
// Check if company key is in the main request
if (isset($this->request['company_key'])) {
nlog("SESWebhook: Found company key in main request: " . $this->request['company_key']);
return $this->request['company_key'];
}
nlog("SESWebhook: No company key found in any location", [
'mail_headers_exists' => isset($this->request['mail']['headers']),
'mail_common_headers_exists' => isset($this->request['mail']['commonHeaders']),
'request_keys' => array_keys($this->request)
]);
return null;
}
/**
* Extract message ID from SES notification
*/
private function extractMessageId(): ?string
{
// Check various possible locations for message ID
if (isset($this->request['mail']['messageId'])) {
nlog("SESWebhook: Found message ID in mail: " . $this->request['mail']['messageId']);
return $this->request['mail']['messageId'];
}
if (isset($this->request['messageId'])) {
nlog("SESWebhook: Found message ID in main request: " . $this->request['messageId']);
return $this->request['messageId'];
}
nlog("SESWebhook: No message ID found in any location");
return null;
}
/**
* Execute the job.
*
*/
public function handle()
{
nlog("SESWebhook: Processing SES webhook data", ['request' => $this->request]);
// Extract company key from SES message tags or metadata
$company_key = $this->extractCompanyKey();
if (!$company_key) {
nlog("SESWebhook: No company key found in webhook data");
return;
}
MultiDB::findAndSetDbByCompanyKey($company_key);
$this->company = Company::query()->where('company_key', $company_key)->first();
if (!$this->company) {
nlog("SESWebhook: Company not found for key: " . $company_key);
return;
}
// Extract message ID from SES notification
$message_id = $this->extractMessageId();
if (!$message_id) {
nlog("SESWebhook: No message ID found in webhook data");
return;
}
$this->invitation = $this->discoverInvitation($message_id);
if (!$this->invitation) {
nlog("SESWebhook: No invitation found for message ID: " . $message_id);
return;
}
// Handle different SES notification types
$notification_type = $this->request['eventType'] ?? $this->request['Type'] ?? $this->request['notificationType'] ?? '';
switch ($notification_type) {
case 'Delivery':
case 'Received':
return $this->processDelivery();
case 'Bounce':
return $this->processBounce();
case 'Complaint':
return $this->processComplaint();
case 'Open':
case 'Rendering Failure':
return $this->processOpen();
default:
nlog("SESWebhook: Unknown notification type: " . $notification_type);
break;
}
}
/**
* Process email delivery confirmation
*/
private function processDelivery()
{
$this->invitation->email_status = 'delivered';
$this->invitation->saveQuietly();
$this->request['MessageID'] = $this->extractMessageId();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_DELIVERY,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process email bounce
*/
private function processBounce()
{
$this->invitation->email_status = 'bounced';
$this->invitation->saveQuietly();
// Check if this is a confirmation email bounce
$subject = $this->request['bounce']['bouncedRecipients'][0]['email'] ?? '';
if ($subject == ctrans('texts.confirmation_subject')) {
$this->company->notification(new EmailBounceNotification($subject))->ninja();
}
$bounce = new EmailBounce(
$this->extractCompanyKey(),
$this->request['mail']['source'] ?? '',
$this->extractMessageId()
);
LightLogs::create($bounce)->send();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_BOUNCED,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process spam complaint
*/
private function processComplaint()
{
$this->invitation->email_status = 'spam';
$this->invitation->saveQuietly();
if (config('ninja.notification.slack')) {
$this->company->notification(new EmailSpamNotification($this->company))->ninja();
}
$spam = new EmailSpam(
$this->extractCompanyKey(),
$this->request['mail']['source'] ?? '',
$this->extractMessageId()
);
LightLogs::create($spam)->send();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_SPAM_COMPLAINT,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process email open
*/
private function processOpen()
{
$this->invitation->opened_date = now();
$this->invitation->saveQuietly();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_OPENED,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Discover invitation by message ID
*/
private function discoverInvitation($message_id)
{
$invitation = false;
if ($invitation = InvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'invoice';
return $invitation;
} elseif ($invitation = QuoteInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'quote';
return $invitation;
} elseif ($invitation = RecurringInvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'recurring_invoice';
return $invitation;
} elseif ($invitation = CreditInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'credit';
return $invitation;
} elseif ($invitation = PurchaseOrderInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'purchase_order';
return $invitation;
} else {
return $invitation;
}
}
/**
* Fetch message details and create response data
*/
private function fetchMessage(): array
{
$message_id = $this->extractMessageId();
if (strlen($message_id) < 1) {
return $this->default_response;
}
try {
// Extract information from SES webhook data
$recipients = $this->extractRecipients();
$subject = $this->extractSubject();
$events = $this->extractEvents();
$event_type = $this->getCurrentEventType();
return [
'recipients' => $recipients,
'subject' => $subject,
'entity' => $this->entity ?? '',
'entity_id' => $this->invitation->{$this->entity}->hashed_id ?? '',
'events' => [$this->extractEventData()], // Start with single event in array
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
} catch (\Exception $e) {
nlog("SESWebhook: Error fetching message: " . $e->getMessage());
return $this->default_response;
}
}
/**
* Extract recipients from SES webhook data
*/
private function extractRecipients(): string
{
if (isset($this->request['mail']['destination'])) {
return is_array($this->request['mail']['destination'])
? implode(',', $this->request['mail']['destination'])
: $this->request['mail']['destination'];
}
if (isset($this->request['bounce']['bouncedRecipients'])) {
return collect($this->request['bounce']['bouncedRecipients'])
->pluck('emailAddress')
->implode(',');
}
if (isset($this->request['complaint']['complainedRecipients'])) {
return collect($this->request['complaint']['complainedRecipients'])
->pluck('emailAddress')
->implode(',');
}
return '';
}
/**
* Extract subject from SES webhook data
*/
private function extractSubject(): string
{
return $this->request['mail']['commonHeaders']['subject'] ??
$this->request['bounce']['bouncedRecipients'][0]['email'] ??
'';
}
/**
* Extract events from the webhook data
*/
private function extractEvents(): array
{
$event_type = $this->getCurrentEventType();
$message_id = $this->extractMessageId();
switch ($event_type) {
case 'delivery':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Delivered',
'delivery_message' => $this->request['delivery']['smtpResponse'] ?? 'Successfully delivered',
'server' => $this->request['delivery']['processingTimeMillis'] ?? '',
'server_ip' => $this->request['delivery']['remoteMtaIp'] ?? '',
'date' => $this->request['delivery']['timestamp'] ?? now()->toISOString()
];
case 'bounce':
$bounce_data = $this->request['bounce'] ?? [];
return [
'bounce_id' => $bounce_data['bounceId'] ?? '',
'recipient' => $this->extractRecipients(),
'status' => 'Bounced',
'bounce_type' => $bounce_data['bounceType'] ?? '',
'bounce_sub_type' => $bounce_data['bounceSubType'] ?? '',
'date' => $bounce_data['timestamp'] ?? now()->toISOString()
];
case 'complaint':
$complaint_data = $this->request['complaint'] ?? [];
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Spam Complaint',
'complaint_type' => $complaint_data['complaintFeedbackType'] ?? '',
'date' => $complaint_data['timestamp'] ?? now()->toISOString()
];
case 'open':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Opened',
'date' => now()->toISOString()
];
default:
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Unknown',
'date' => now()->toISOString()
];
}
}
/**
* Extract event data for the current webhook
*/
private function extractEventData(): array
{
$event_type = $this->getCurrentEventType();
$message_id = $this->extractMessageId();
switch ($event_type) {
case 'delivery':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Delivered',
'delivery_message' => $this->request['delivery']['smtpResponse'] ?? 'Successfully delivered',
'server' => $this->request['delivery']['processingTimeMillis'] ?? '',
'server_ip' => $this->request['delivery']['remoteMtaIp'] ?? '',
'date' => $this->request['delivery']['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'bounce':
$bounce_data = $this->request['bounce'] ?? [];
return [
'bounce_id' => $bounce_data['bounceId'] ?? '',
'recipient' => $this->extractRecipients(),
'status' => 'Bounced',
'bounce_type' => $bounce_data['bounceType'] ?? '',
'bounce_sub_type' => $bounce_data['bounceSubType'] ?? '',
'date' => $bounce_data['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'complaint':
$complaint_data = $this->request['complaint'] ?? [];
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Spam Complaint',
'complaint_type' => $complaint_data['complaintFeedbackType'] ?? '',
'date' => $complaint_data['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'open':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Opened',
'date' => now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
default:
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Unknown',
'date' => now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
}
}
/**
* Handle job failure
*/
public function failed($exception = null)
{
if ($exception) {
nlog("SESWebhook:: " . $exception->getMessage());
}
config(['queue.failed.driver' => null]);
}
}

View File

@@ -45,6 +45,7 @@ class MailSentListener
{
try {
$message_id = $event->sent->getMessageId();
$message = MessageConverter::toEmail($event->sent->getOriginalMessage()); //@phpstan-ignore-line
@@ -53,6 +54,10 @@ class MailSentListener
return;
}
if($message->getHeaders()->get('x-message-id')) {
$message_id = $message->getHeaders()->get('x-message-id')->getValue();
}
$invitation_key = $message->getHeaders()->get('x-invitation')->getValue();
if ($message_id && $invitation_key) {

View File

@@ -23,6 +23,7 @@ class DeletePayment
{
private float $_paid_to_date_deleted = 0;
private float $total_payment_amount = 0;
/**
* @param Payment $payment
* @return void
@@ -91,6 +92,8 @@ class DeletePayment
$invoice_ids = $this->payment->invoices()->pluck('invoices.id')->toArray();
$this->total_payment_amount = $this->payment->amount + ($this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('amount') - $this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('refunded'));
$this->payment->invoices()->each(function ($paymentable_invoice) {
$net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded;
@@ -144,7 +147,8 @@ class DeletePayment
$this->payment
->client
->service()
->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down.
->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 )) // if negative, set to 0, the paid to date will be reduced further down.
// ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down.
->save();
if (abs(floatval($paymentable_invoice->balance) - floatval($paymentable_invoice->amount)) < 0.005) {
@@ -172,8 +176,14 @@ class DeletePayment
//sometimes the payment is NOT created properly, this catches the payment and prevents the paid to date reducing inappropriately.
if ($this->update_client_paid_to_date) {
// $reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1);
$reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1);
/** handle the edge case where a partial credit + unapplied payment is deleted */
if(floatval($this->total_payment_amount) != floatval($this->_paid_to_date_deleted)) {
$reduced_paid_to_date = ($this->total_payment_amount - $this->_paid_to_date_deleted) * -1;
}
nlog("reduced paid to date: {$reduced_paid_to_date}");
if($reduced_paid_to_date != 0) {
$this->payment

View File

@@ -73,8 +73,8 @@ return [
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'key' => env('SES_AWS_ACCESS_KEY_ID'),
'secret' => env('SES_AWS_SECRET_ACCESS_KEY'),
'region' => env('SES_REGION', 'us-east-1'),
],

View File

@@ -11,6 +11,7 @@
|
*/
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SNSController;
use App\Http\Controllers\BaseController;
use App\Http\Controllers\PingController;
use App\Http\Controllers\SmtpController;
@@ -413,7 +414,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::post('settings/enable_two_factor', [TwoFactorController::class, 'enableTwoFactor']);
Route::post('settings/disable_two_factor', [TwoFactorController::class, 'disableTwoFactor']);
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:3,1');
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:1,1');
Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm');
Route::resource('vendors', VendorController::class); // name = (vendors. index / create / show / update / destroy / edit
@@ -478,6 +479,7 @@ Route::match(['get', 'post'], 'payment_notification_webhook/{company_key}/{compa
->name('payment_notification_webhook');
Route::post('api/v1/postmark_webhook', [PostMarkController::class, 'webhook'])->middleware('throttle:5000,1');
Route::post('api/v1/sns_webhook', [SNSController::class, 'webhook'])->middleware('throttle:5000,1');
Route::post('api/v1/postmark_inbound_webhook', [PostMarkController::class, 'inboundWebhook'])->middleware('throttle:1000,1');
Route::post('api/v1/mailgun_webhook', [MailgunController::class, 'webhook'])->middleware('throttle:1000,1');
Route::post('api/v1/mailgun_inbound_webhook', [MailgunController::class, 'inboundWebhook'])->middleware('throttle:1000,1');

View File

@@ -189,14 +189,25 @@ class UnappliedPaymentDeleteTest extends TestCase
$payment_hashed_id = $arr['data']['id'];
$payment = Payment::find($this->decodePrimaryKey($payment_hashed_id));
$payment->invoices()->each(function ($i){
$this->assertEquals(20, $i->pivot->amount);
$this->assertEquals(0, $i->pivot->refunded);
});
$this->assertEquals(30, $payment->amount);
$this->assertEquals(20, $payment->applied);
$this->assertEquals(30, $client->fresh()->paid_to_date);
$payment->service()->deletePayment();
$payment->fresh();
$invoice->fresh();
$this->assertEquals(20, $invoice->fresh()->balance);
$this->assertEquals(0, $invoice->fresh()->paid_to_date);
$this->assertEquals(0, $client->fresh()->paid_to_date);
$this->assertEquals(20, $client->fresh()->balance);
}