Page MenuHomePhorge

No OneTemporary

Size
341 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php
index 9266330b..091096b0 100644
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -1,983 +1,985 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
use App\User;
class LDAP
{
/** @const array UserSettings used by the backend */
public const USER_SETTINGS = [
'first_name',
'last_name',
'organization',
];
/** @var ?\Net_LDAP3 LDAP connection object */
protected static $ldap;
/**
* Starts a new LDAP connection that will be used by all methods
* until you call self::disconnect() explicitely. Normally every
* method uses a separate connection.
*
* @throws \Exception
*/
public static function connect(): void
{
if (empty(self::$ldap)) {
$config = self::getConfig('admin');
self::$ldap = self::initLDAP($config);
}
}
/**
* Close the connection created by self::connect()
*/
public static function disconnect(): void
{
if (!empty(self::$ldap)) {
self::$ldap->close();
self::$ldap = null;
}
}
/**
* Create a domain in LDAP.
*
* @param \App\Domain $domain The domain to create.
*
* @throws \Exception
*/
public static function createDomain(Domain $domain): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$hostedRootDN = \config('ldap.hosted.root_dn');
$mgmtRootDN = \config('ldap.admin.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
$aci = [
'(targetattr = "*")'
. '(version 3.0; acl "Deny Unauthorized"; deny (all)'
. '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") '
. 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)',
'(targetattr != "userPassword")'
. '(version 3.0;acl "Search Access";allow (read,compare,search)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)',
'(targetattr = "*")'
. '(version 3.0;acl "Kolab Administrators";allow (all)'
. '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN
. ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)'
];
$entry = [
'aci' => $aci,
'associateddomain' => $domain->namespace,
'inetdomainbasedn' => $domainBaseDN,
'objectclass' => [
'top',
'domainrelatedobject',
'inetdomain'
],
];
$dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}";
self::setDomainAttributes($domain, $entry);
if (!$ldap->get_entry($dn)) {
$result = $ldap->add_entry($dn, $entry);
if (!$result) {
self::throwException(
$ldap,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
}
// create ou, roles, ous
$entry = [
'description' => $domain->namespace,
'objectclass' => [
'top',
'organizationalunit'
],
'ou' => $domain->namespace,
];
$entry['aci'] = array(
'(targetattr = "*")'
. '(version 3.0;acl "Deny Unauthorized"; deny (all)'
. '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") '
. 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)',
'(targetattr != "userPassword")'
. '(version 3.0;acl "Search Access";allow (read,compare,search,write)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)',
'(targetattr = "*")'
. '(version 3.0;acl "Kolab Administrators";allow (all)'
. '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN
. ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)',
'(target = "ldap:///ou=*,' . $domainBaseDN . '")'
. '(targetattr="objectclass || aci || ou")'
. '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)',
'(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")'
. '(version 3.0;acl "Allow Domain First User Registration"; allow (add)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)',
'(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")'
. '(version 3.0;acl "Allow Domain Role Registration"; allow (add)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)',
);
if (!$ldap->get_entry($domainBaseDN)) {
$result = $ldap->add_entry($domainBaseDN, $entry);
if (!$result) {
self::throwException(
$ldap,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
}
foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) {
if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) {
$result = $ldap->add_entry(
"ou={$item},{$domainBaseDN}",
[
'ou' => $item,
'description' => $item,
'objectclass' => [
'top',
'organizationalunit'
]
]
);
if (!$result) {
self::throwException(
$ldap,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
}
}
foreach (['kolab-admin'] as $item) {
if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) {
$result = $ldap->add_entry(
"cn={$item},{$domainBaseDN}",
[
'cn' => $item,
'description' => "{$item} role",
'objectclass' => [
'top',
'ldapsubentry',
'nsmanagedroledefinition',
'nsroledefinition',
'nssimpleroledefinition'
]
]
);
if (!$result) {
self::throwException(
$ldap,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
}
}
// TODO: Assign kolab-admin role to the owner?
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Create a group in LDAP.
*
* @param \App\Group $group The group to create.
*
* @throws \Exception
*/
public static function createGroup(Group $group): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
list($cn, $domainName) = explode('@', $group->email);
$domain = $group->domain();
if (empty($domain)) {
self::throwException(
$ldap,
"Failed to create group {$group->email} in LDAP (" . __LINE__ . ")"
);
}
$hostedRootDN = \config('ldap.hosted.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
$groupBaseDN = "ou=Groups,{$domainBaseDN}";
$dn = "cn={$cn},{$groupBaseDN}";
$entry = [
'cn' => $cn,
'mail' => $group->email,
'objectclass' => [
'top',
'groupofuniquenames',
'kolabgroupofuniquenames'
],
'uniquemember' => []
];
self::setGroupAttributes($ldap, $group, $entry);
$result = $ldap->add_entry($dn, $entry);
if (!$result) {
self::throwException(
$ldap,
"Failed to create group {$group->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Create a user in LDAP.
*
* Only need to add user if in any of the local domains? Figure that out here for now. Should
* have Context-Based Access Controls before the job is queued though, probably.
*
* Use one of three modes;
*
* 1) The authenticated user account.
*
* * Only valid if the authenticated user is a domain admin.
* * We don't know the originating user here.
* * We certainly don't have its password anymore.
*
* 2) The hosted kolab account.
*
* 3) The Directory Manager account.
*
* @param \App\User $user The user account to create.
*
* @throws \Exception
*/
public static function createUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$entry = [
'objectclass' => [
'top',
'inetorgperson',
'inetuser',
'kolabinetorgperson',
'mailrecipient',
'person'
],
'mail' => $user->email,
'uid' => $user->email,
'nsroledn' => []
];
if (!self::getUserEntry($ldap, $user->email, $dn)) {
if (empty($dn)) {
self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")");
}
self::setUserAttributes($user, $entry);
$result = $ldap->add_entry($dn, $entry);
if (!$result) {
self::throwException(
$ldap,
"Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a domain from LDAP.
*
* @param \App\Domain $domain The domain to delete
*
* @throws \Exception
*/
public static function deleteDomain(Domain $domain): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$hostedRootDN = \config('ldap.hosted.root_dn');
$mgmtRootDN = \config('ldap.admin.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
if ($ldap->get_entry($domainBaseDN)) {
$result = $ldap->delete_entry_recursive($domainBaseDN);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")"
);
}
}
if ($ldap_domain = $ldap->find_domain($domain->namespace)) {
if ($ldap->get_entry($ldap_domain['dn'])) {
$result = $ldap->delete_entry($ldap_domain['dn']);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")"
);
}
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a group from LDAP.
*
* @param \App\Group $group The group to delete.
*
* @throws \Exception
*/
public static function deleteGroup(Group $group): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getGroupEntry($ldap, $group->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a user from LDAP.
*
* @param \App\User $user The user account to delete.
*
* @throws \Exception
*/
public static function deleteUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getUserEntry($ldap, $user->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Get a domain data from LDAP.
*
* @param string $namespace The domain name
*
* @return array|false|null
* @throws \Exception
*/
public static function getDomain(string $namespace)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$ldapDomain = $ldap->find_domain($namespace);
if ($ldapDomain) {
$domain = $ldap->get_entry($ldapDomain['dn']);
}
if (empty(self::$ldap)) {
$ldap->close();
}
return $domain ?? null;
}
/**
* Get a group data from LDAP.
*
* @param string $email The group email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getGroup(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$group = self::getGroupEntry($ldap, $email, $dn);
if (empty(self::$ldap)) {
$ldap->close();
}
return $group;
}
/**
* Get a user data from LDAP.
*
* @param string $email The user email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getUser(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$user = self::getUserEntry($ldap, $email, $dn, true);
if (empty(self::$ldap)) {
$ldap->close();
}
return $user;
}
/**
* Update a domain in LDAP.
*
* @param \App\Domain $domain The domain to update.
*
* @throws \Exception
*/
public static function updateDomain(Domain $domain): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$ldapDomain = $ldap->find_domain($domain->namespace);
if (!$ldapDomain) {
self::throwException(
$ldap,
"Failed to update domain {$domain->namespace} in LDAP (domain not found)"
);
}
$oldEntry = $ldap->get_entry($ldapDomain['dn']);
$newEntry = $oldEntry;
self::setDomainAttributes($domain, $newEntry);
if (array_key_exists('inetdomainstatus', $newEntry)) {
$newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus'];
}
$result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Update a group in LDAP.
*
* @param \App\Group $group The group to update
*
* @throws \Exception
*/
public static function updateGroup(Group $group): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
list($cn, $domainName) = explode('@', $group->email);
$domain = $group->domain();
if (empty($domain)) {
self::throwException(
$ldap,
"Failed to update group {$group->email} in LDAP (" . __LINE__ . ")"
);
}
$hostedRootDN = \config('ldap.hosted.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
$groupBaseDN = "ou=Groups,{$domainBaseDN}";
$dn = "cn={$cn},{$groupBaseDN}";
$entry = [
'cn' => $cn,
'mail' => $group->email,
'objectclass' => [
'top',
'groupofuniquenames',
'kolabgroupofuniquenames'
],
'uniquemember' => []
];
$oldEntry = $ldap->get_entry($dn);
self::setGroupAttributes($ldap, $group, $entry);
$result = $ldap->modify_entry($dn, $oldEntry, $entry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update group {$group->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Update a user in LDAP.
*
* @param \App\User $user The user account to update.
*
* @throws \Exception
*/
public static function updateUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true);
if (!$oldEntry) {
self::throwException(
$ldap,
"Failed to update user {$user->email} in LDAP (user not found)"
);
}
self::setUserAttributes($user, $newEntry);
if (array_key_exists('objectclass', $newEntry)) {
if (!in_array('inetuser', $newEntry['objectclass'])) {
$newEntry['objectclass'][] = 'inetuser';
}
}
if (array_key_exists('inetuserstatus', $newEntry)) {
$newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus'];
}
if (array_key_exists('mailquota', $newEntry)) {
$newEntry['mailquota'] = (string) $newEntry['mailquota'];
}
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update user {$user->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Initialize connection to LDAP
*/
private static function initLDAP(array $config, string $privilege = 'admin')
{
if (self::$ldap) {
return self::$ldap;
}
$ldap = new \Net_LDAP3($config);
$connected = $ldap->connect();
if (!$connected) {
throw new \Exception("Failed to connect to LDAP");
}
$bound = $ldap->bind(
\config("ldap.{$privilege}.bind_dn"),
\config("ldap.{$privilege}.bind_pw")
);
if (!$bound) {
throw new \Exception("Failed to bind to LDAP");
}
return $ldap;
}
/**
* Set domain attributes
*/
private static function setDomainAttributes(Domain $domain, array &$entry)
{
$entry['inetdomainstatus'] = $domain->status;
}
/**
* Convert group member addresses in to valid entries.
*/
private static function setGroupAttributes($ldap, Group $group, &$entry)
{
$validMembers = [];
$domain = $group->domain();
$hostedRootDN = \config('ldap.hosted.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
foreach ($group->members as $member) {
list($local, $domainName) = explode('@', $member);
$memberDN = "uid={$member},ou=People,{$domainBaseDN}";
$memberEntry = $ldap->get_entry($memberDN);
// if the member is in the local domain but doesn't exist, drop it
if ($domainName == $domain->namespace && !$memberEntry) {
continue;
}
// add the member if not in the local domain
if (!$memberEntry) {
$memberEntry = [
'cn' => $member,
'mail' => $member,
'objectclass' => [
'top',
'inetorgperson',
'organizationalperson',
'person'
],
'sn' => 'unknown'
];
$ldap->add_entry($memberDN, $memberEntry);
}
$entry['uniquemember'][] = $memberDN;
$validMembers[] = $member;
}
// Update members in sql (some might have been removed),
// skip model events to not invoke another update job
$group->members = $validMembers;
Group::withoutEvents(function () use ($group) {
$group->save();
});
}
/**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
{
- $firstName = $user->getSetting('first_name');
- $lastName = $user->getSetting('last_name');
+ $settings = $user->getSettings(['first_name', 'last_name', 'organization']);
+
+ $firstName = $settings['first_name'];
+ $lastName = $settings['last_name'];
$cn = "unknown";
$displayname = "";
if ($firstName) {
if ($lastName) {
$cn = "{$firstName} {$lastName}";
$displayname = "{$lastName}, {$firstName}";
} else {
$lastName = "unknown";
$cn = "{$firstName}";
$displayname = "{$firstName}";
}
} else {
$firstName = "";
if ($lastName) {
$cn = "{$lastName}";
$displayname = "{$lastName}";
} else {
$lastName = "unknown";
}
}
$entry['cn'] = $cn;
$entry['displayname'] = $displayname;
$entry['givenname'] = $firstName;
$entry['sn'] = $lastName;
$entry['userpassword'] = $user->password_ldap;
$entry['inetuserstatus'] = $user->status;
- $entry['o'] = $user->getSetting('organization');
+ $entry['o'] = $settings['organization'];
$entry['mailquota'] = 0;
$entry['alias'] = $user->aliases->pluck('alias')->toArray();
$roles = [];
foreach ($user->entitlements as $entitlement) {
\Log::debug("Examining {$entitlement->sku->title}");
switch ($entitlement->sku->title) {
case "mailbox":
break;
case "storage":
$entry['mailquota'] += 1048576;
break;
default:
$roles[] = $entitlement->sku->title;
break;
}
}
$hostedRootDN = \config('ldap.hosted.root_dn');
$entry['nsroledn'] = [];
if (in_array("2fa", $roles)) {
$entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}";
}
if (in_array("activesync", $roles)) {
$entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}";
}
if (!in_array("groupware", $roles)) {
$entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}";
}
}
/**
* Get LDAP configuration for specified access level
*/
private static function getConfig(string $privilege)
{
$config = [
'domain_base_dn' => \config('ldap.domain_base_dn'),
'domain_filter' => \config('ldap.domain_filter'),
'domain_name_attribute' => \config('ldap.domain_name_attribute'),
'hosts' => \config('ldap.hosts'),
'sort' => false,
'vlv' => false,
'log_hook' => 'App\Backends\LDAP::logHook',
];
return $config;
}
/**
* Get group entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email Group email (mail)
* @param string $dn Reference to group DN
*
* @return false|null|array Group entry, False on error, NULL if not found
*/
private static function getGroupEntry($ldap, $email, &$dn = null)
{
list($_local, $_domain) = explode('@', $email, 2);
$domain = $ldap->find_domain($_domain);
if (!$domain) {
return $domain;
}
$base_dn = $ldap->domain_root_dn($_domain);
$dn = "cn={$_local},ou=Groups,{$base_dn}";
$entry = $ldap->get_entry($dn);
return $entry ?: null;
}
/**
* Get user entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email User email (uid)
* @param string $dn Reference to user DN
* @param bool $full Get extra attributes, e.g. nsroledn
*
* @return false|null|array User entry, False on error, NULL if not found
*/
private static function getUserEntry($ldap, $email, &$dn = null, $full = false)
{
list($_local, $_domain) = explode('@', $email, 2);
$domain = $ldap->find_domain($_domain);
if (!$domain) {
return $domain;
}
$base_dn = $ldap->domain_root_dn($_domain);
$dn = "uid={$email},ou=People,{$base_dn}";
$entry = $ldap->get_entry($dn);
if ($entry && $full) {
if (!array_key_exists('nsroledn', $entry)) {
$roles = $ldap->get_entry_attributes($dn, ['nsroledn']);
if (!empty($roles)) {
$entry['nsroledn'] = (array) $roles['nsroledn'];
}
}
}
return $entry ?: null;
}
/**
* Logging callback
*/
public static function logHook($level, $msg): void
{
if (
(
$level == LOG_INFO
|| $level == LOG_DEBUG
|| $level == LOG_NOTICE
)
&& !\config('app.debug')
) {
return;
}
switch ($level) {
case LOG_CRIT:
$function = 'critical';
break;
case LOG_EMERG:
$function = 'emergency';
break;
case LOG_ERR:
$function = 'error';
break;
case LOG_ALERT:
$function = 'alert';
break;
case LOG_WARNING:
$function = 'warning';
break;
case LOG_INFO:
$function = 'info';
break;
case LOG_DEBUG:
$function = 'debug';
break;
case LOG_NOTICE:
$function = 'notice';
break;
default:
$function = 'info';
}
if (is_array($msg)) {
$msg = implode("\n", $msg);
}
$msg = '[LDAP] ' . $msg;
\Log::{$function}($msg);
}
/**
* Throw exception and close the connection when needed
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $message Exception message
*
* @throws \Exception
*/
private static function throwException($ldap, string $message): void
{
if (empty(self::$ldap) && !empty($ldap)) {
$ldap->close();
}
throw new \Exception($message);
}
}
diff --git a/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php b/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php
new file mode 100644
index 00000000..1ba8e11d
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TenantSetting/CreateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TenantSetting;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $cacheKeys = ['app\tenant_settings_%tenant_id%'];
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TenantSetting::class;
+ protected $objectName = 'tenant-setting';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/TenantSetting/ReadCommand.php b/src/app/Console/Commands/Scalpel/TenantSetting/ReadCommand.php
new file mode 100644
index 00000000..1b4b7142
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TenantSetting/ReadCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TenantSetting;
+
+use App\Console\ObjectReadCommand;
+
+class ReadCommand extends ObjectReadCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TenantSetting::class;
+ protected $objectName = 'tenant-setting';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/TenantSetting/UpdateCommand.php b/src/app/Console/Commands/Scalpel/TenantSetting/UpdateCommand.php
new file mode 100644
index 00000000..e043739b
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TenantSetting/UpdateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TenantSetting;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $cacheKeys = ['app\tenant_settings_%tenant_id%'];
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TenantSetting::class;
+ protected $objectName = 'tenant-setting';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Tenant/ListSettingsCommand.php b/src/app/Console/Commands/Tenant/ListSettingsCommand.php
new file mode 100644
index 00000000..1b5b8d48
--- /dev/null
+++ b/src/app/Console/Commands/Tenant/ListSettingsCommand.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Console\Commands\Tenant;
+
+use App\Console\Command;
+
+class ListSettingsCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'tenant:list-settings {tenant}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'List settings for the tenant.';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $tenant = $this->getObject(\App\Tenant::class, $this->argument('tenant'), 'title');
+
+ if (!$tenant) {
+ $this->error("Unable to find the tenant.");
+ return 1;
+ }
+
+ $tenant->settings()->orderBy('key')->get()
+ ->each(function ($entry) {
+ $text = "{$entry->key}: {$entry->value}";
+ $this->info($text);
+ });
+ }
+}
diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php
index d9c48975..488f9e67 100644
--- a/src/app/Documents/Receipt.php
+++ b/src/app/Documents/Receipt.php
@@ -1,278 +1,277 @@
<?php
namespace App\Documents;
use App\Payment;
use App\Providers\PaymentProvider;
use App\User;
use App\Wallet;
use Carbon\Carbon;
class Receipt
{
/** @var \App\Wallet The wallet */
protected $wallet;
/** @var int Transactions date year */
protected $year;
/** @var int Transactions date month */
protected $month;
/** @var bool Enable fake data mode */
protected static $fakeMode = false;
/**
* Document constructor.
*
* @param \App\Wallet $wallet A wallet containing transactions
* @param int $year A year to list transactions from
* @param int $month A month to list transactions from
*
* @return void
*/
public function __construct(Wallet $wallet, int $year, int $month)
{
$this->wallet = $wallet;
$this->year = $year;
$this->month = $month;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'pdf')
*
* @return string HTML or PDF output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$wallet->id = \App\Utils::uuidStr();
$wallet->owner = new User(['id' => 123456789]); // @phpstan-ignore-line
$receipt = new self($wallet, date('Y'), date('n'));
self::$fakeMode = true;
if ($type == 'pdf') {
return $receipt->pdfOutput();
} elseif ($type !== 'html') {
throw new \Exception("Unsupported output format");
}
return $receipt->htmlOutput();
}
/**
* Render the receipt in HTML format.
*
* @return string HTML content
*/
public function htmlOutput(): string
{
return $this->build()->render();
}
/**
* Render the receipt in PDF format.
*
* @return string PDF content
*/
public function pdfOutput(): string
{
// Parse ther HTML template
$html = $this->build()->render();
// Link fonts from public/fonts to storage/fonts so DomPdf can find them
if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) {
symlink(
public_path('fonts/Roboto-Regular.ttf'),
storage_path('fonts/Roboto-Regular.ttf')
);
symlink(
public_path('fonts/Roboto-Bold.ttf'),
storage_path('fonts/Roboto-Bold.ttf')
);
}
// Fix font and image paths
$html = str_replace('url(/fonts/', 'url(fonts/', $html);
$html = str_replace('src="/', 'src="', $html);
// TODO: The output file is about ~200KB, we could probably slim it down
// by using separate font files with small subset of languages when
// there are no Unicode characters used, e.g. only ASCII or Latin.
// Load PDF generator
$pdf = \PDF::loadHTML($html)->setPaper('a4', 'portrait');
return $pdf->output();
}
/**
* Build the document
*
* @return \Illuminate\View\View The template object
*/
protected function build()
{
$appName = \config('app.name');
$start = Carbon::create($this->year, $this->month, 1, 0, 0, 0);
$end = $start->copy()->endOfMonth();
$month = \trans('documents.month' . intval($this->month));
$title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]);
$company = $this->companyData();
if (self::$fakeMode) {
$country = 'CH';
$customer = [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => 'Freddie Krüger<br>7252 Westminster Lane<br>Forest Hills, NY 11375',
];
$items = collect([
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next(Carbon::MONDAY),
],
(object) [
'amount' => 10000,
'updated_at' => $start->copy()->next()->next(),
],
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY),
],
(object) [
'amount' => 99,
'updated_at' => $start->copy()->next()->next()->next(),
],
]);
} else {
$customer = $this->customerData();
$country = $this->wallet->owner->getSetting('country');
$items = $this->wallet->payments()
->where('status', PaymentProvider::STATUS_PAID)
->where('updated_at', '>=', $start)
->where('updated_at', '<', $end)
->where('amount', '<>', 0)
->orderBy('updated_at')
->get();
}
$vatRate = \config('app.vat.rate');
$vatCountries = explode(',', \config('app.vat.countries'));
$vatCountries = array_map('strtoupper', array_map('trim', $vatCountries));
if (!$country || !in_array(strtoupper($country), $vatCountries)) {
$vatRate = 0;
}
$totalVat = 0;
$total = 0;
$items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) {
$amount = $item->amount;
if ($vatRate > 0) {
$amount = round($amount * ((100 - $vatRate) / 100));
$totalVat += $item->amount - $amount;
}
$total += $amount;
$type = $item->type ?? null;
if ($type == PaymentProvider::TYPE_REFUND) {
$description = \trans('documents.receipt-refund');
} elseif ($type == PaymentProvider::TYPE_CHARGEBACK) {
$description = \trans('documents.receipt-chargeback');
} else {
$description = \trans('documents.receipt-item-desc', ['site' => $appName]);
}
return [
'amount' => $this->wallet->money($amount),
'description' => $description,
'date' => $item->updated_at->toDateString(),
];
});
// Load the template
$view = view('documents.receipt')
->with([
'site' => $appName,
'title' => $title,
'company' => $company,
'customer' => $customer,
'items' => $items,
'subTotal' => $this->wallet->money($total),
'total' => $this->wallet->money($total + $totalVat),
'totalVat' => $this->wallet->money($totalVat),
'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)),
'vat' => $vatRate > 0,
]);
return $view;
}
/**
* Prepare customer data for the template
*
* @return array Customer data for the template
*/
protected function customerData(): array
{
$user = $this->wallet->owner;
$name = $user->name();
- $organization = $user->getSetting('organization');
- $address = $user->getSetting('billing_address');
+ $settings = $user->getSettings(['organization', 'billing_address']);
- $customer = trim(($organization ?: $name) . "\n$address");
+ $customer = trim(($settings['organization'] ?: $name) . "\n" . $settings['billing_address']);
$customer = str_replace("\n", '<br>', htmlentities($customer));
return [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => $customer,
];
}
/**
* Prepare company data for the template
*
* @return array Company data for the template
*/
protected function companyData(): array
{
$header = \config('app.company.name') . "\n" . \config('app.company.address');
$header = str_replace("\n", '<br>', htmlentities($header));
$footerLineLength = 110;
$footer = \config('app.company.details');
$contact = \config('app.company.email');
$logo = \config('app.company.logo');
$theme = \config('app.theme');
if ($contact) {
$length = strlen($footer) + strlen($contact) + 3;
$contact = htmlentities($contact);
$footer .= ($length > $footerLineLength ? "\n" : ' | ')
. sprintf('<a href="mailto:%s">%s</a>', $contact, $contact);
}
if ($logo && strpos($logo, '/') === false) {
$logo = "/themes/$theme/images/$logo";
}
return [
'logo' => $logo ? "<img src=\"$logo\" width=300>" : '',
'header' => $header,
'footer' => $footer,
];
}
}
diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index 21d00c0a..a24456ff 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,589 +1,590 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class OpenViduController extends Controller
{
public const AUTH_HEADER = 'X-Meet-Auth-Token';
/**
* Accept the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function acceptJoinRequest($id, $reqid)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestAccept($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-accept-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Deny the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function denyJoinRequest($id, $reqid)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestDeny($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-deny-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Close the room session.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function closeRoom($id)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$user = Auth::guard()->user();
// Only the room owner can do it
if (!$user || $user->id != $room->user_id) {
return $this->errorResponse(403);
}
if (!$room->deleteSession()) {
return $this->errorResponse(500, \trans('meet.session-close-error'));
}
return response()->json([
'status' => 'success',
'message' => __('meet.session-close-success'),
]);
}
/**
* Create a connection for screen sharing.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function createConnection($id)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$connection = $this->getConnectionFromRequest();
if (
!$connection
|| $connection->session_id != $room->session_id
|| ($connection->role & Room::ROLE_PUBLISHER) == 0
) {
return $this->errorResponse(403);
}
$response = $room->getSessionToken(Room::ROLE_SCREEN);
return response()->json(['status' => 'success', 'token' => $response['token']]);
}
/**
* Dismiss the participant/connection from the session.
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function dismissConnection($id, $conn)
{
$connection = Connection::where('id', $conn)->first();
// There's no such connection, bye bye
if (!$connection || $connection->room->name != $id) {
return $this->errorResponse(404, \trans('meet.connection-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
if (!$connection->dismiss()) {
return $this->errorResponse(500, \trans('meet.connection-dismiss-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Listing of rooms that belong to the authenticated user.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = Auth::guard()->user();
$rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
if (count($rooms) == 0) {
// Create a room for the user (with a random and unique name)
while (true) {
$name = strtolower(\App\Utils::randStr(3, 3, '-'));
if (!Room::where('name', $name)->count()) {
break;
}
}
$room = Room::create([
'name' => $name,
'user_id' => $user->id
]);
$rooms = collect([$room]);
}
$result = [
'list' => $rooms,
'count' => count($rooms),
];
return response()->json($result);
}
/**
* Join the room session. Each room has one owner, and the room isn't open until the owner
* joins (and effectively creates the session).
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function joinRoom($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Check if there's still a valid meet entitlement for the room owner
if (!$room->owner->hasSku('meet')) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$user = Auth::guard()->user();
$isOwner = $user && $user->id == $room->user_id;
$init = !empty(request()->input('init'));
// There's no existing session
if (!$room->hasSession()) {
// Participants can't join the room until the session is created by the owner
if (!$isOwner) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
}
// The room owner can create the session on request
if (!$init) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
}
$session = $room->createSession();
if (empty($session)) {
return $this->errorResponse(500, \trans('meet.session-create-error'));
}
}
- $password = (string) $room->getSetting('password');
+ $settings = $room->getSettings(['locked', 'nomedia', 'password']);
+ $password = (string) $settings['password'];
$config = [
- 'locked' => $room->getSetting('locked') === 'true',
- 'nomedia' => $room->getSetting('nomedia') === 'true',
+ 'locked' => $settings['locked'] === 'true',
+ 'nomedia' => $settings['nomedia'] === 'true',
'password' => $isOwner ? $password : '',
'requires_password' => !$isOwner && strlen($password),
];
$response = ['config' => $config];
// Validate room password
if (!$isOwner && strlen($password)) {
$request_password = request()->input('password');
if ($request_password !== $password) {
return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
}
}
// Handle locked room
if (!$isOwner && $config['locked']) {
$nickname = request()->input('nickname');
$picture = request()->input('picture');
$requestId = request()->input('requestId');
$request = $requestId ? $room->requestGet($requestId) : null;
$error = \trans('meet.session-room-locked-error');
// Request already has been processed (not accepted yet, but it could be denied)
if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
if (!$request) {
if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
if (empty($picture)) {
$svg = file_get_contents(resource_path('images/user.svg'));
$picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
} elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// TODO: Resize when big/make safe the user picture?
$request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
if (!$room->requestSave($requestId, $request)) {
// FIXME: should we use error code 500?
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// Send the request (signal) to the owner
$result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
}
return $this->errorResponse(422, $error, $response + ['code' => 327]);
}
}
// Initialize connection tokens
if ($init) {
// Choose the connection role
$canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
$role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
if ($isOwner) {
$role |= Room::ROLE_MODERATOR;
$role |= Room::ROLE_OWNER;
}
// Create session token for the current user/connection
$response = $room->getSessionToken($role);
if (empty($response)) {
return $this->errorResponse(500, \trans('meet.session-join-error'));
}
// Get up-to-date connections metadata
$response['connections'] = $room->getSessionConnections();
$response_code = 200;
$response['role'] = $role;
$response['config'] = $config;
} else {
$response_code = 422;
$response['code'] = 322;
}
return response()->json($response, $response_code);
}
/**
* Set the domain configuration.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setRoomConfig($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404);
}
$user = Auth::guard()->user();
// Only room owner can configure the room
if ($user->id != $room->user_id) {
return $this->errorResponse(403);
}
$input = request()->input();
$errors = [];
foreach ($input as $key => $value) {
switch ($key) {
case 'password':
if ($value === null || $value === '') {
$input[$key] = null;
} else {
// TODO: Do we have to validate the password in any way?
}
break;
case 'locked':
$input[$key] = $value ? 'true' : null;
break;
case 'nomedia':
$input[$key] = $value ? 'true' : null;
break;
default:
$errors[$key] = \trans('meet.room-unsupported-option-error');
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!empty($input)) {
$room->setSettings($input);
}
return response()->json([
'status' => 'success',
'message' => \trans('meet.room-setconfig-success'),
]);
}
/**
* Update the participant/connection parameters (e.g. role).
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function updateConnection($id, $conn)
{
$connection = Connection::where('id', $conn)->first();
// There's no such connection, bye bye
if (!$connection || $connection->room->name != $id) {
return $this->errorResponse(404, \trans('meet.connection-not-found'));
}
foreach (request()->input() as $key => $value) {
switch ($key) {
case 'hand':
// Only possible on user's own connection(s)
if (!$this->isSelfConnection($connection)) {
return $this->errorResponse(403);
}
if ($value) {
// Store current time, so we know the order in the queue
$connection->metadata = ['hand' => time()] + $connection->metadata;
} else {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
break;
case 'language':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
if ($value) {
if (preg_match('/^[a-z]{2}$/', $value)) {
$connection->metadata = ['language' => $value] + $connection->metadata;
}
} else {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
break;
case 'role':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
// The 'owner' role is not assignable
if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
} elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
}
// The room owner has always a 'moderator' role
if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
$value |= Room::ROLE_MODERATOR;
}
// Promotion to publisher? Put the user hand down
if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
// Non-publisher cannot be a language interpreter
if (!($value & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
$connection->{$key} = $value;
break;
}
}
// The connection observer will send a signal to everyone when needed
$connection->save();
return response()->json(['status' => 'success']);
}
/**
* Webhook as triggered from OpenVidu server
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function webhook(Request $request)
{
\Log::debug($request->getContent());
switch ((string) $request->input('event')) {
case 'sessionDestroyed':
// When all participants left the room OpenVidu dispatches sessionDestroyed
// event. We'll remove the session reference from the database.
$sessionId = $request->input('sessionId');
$room = Room::where('session_id', $sessionId)->first();
if ($room) {
$room->session_id = null;
$room->save();
}
// Remove all connections
// Note: We could remove connections one-by-one via the 'participantLeft' event
// but that could create many INSERTs when the session (with many participants) ends
// So, it is better to remove them all in a single INSERT.
Connection::where('session_id', $sessionId)->delete();
break;
}
return response('Success', 200);
}
/**
* Check if current user is a moderator for the specified room.
*
* @param \App\OpenVidu\Room $room The room
*
* @return bool True if the current user is the room moderator
*/
protected function isModerator(Room $room): bool
{
$user = Auth::guard()->user();
// The room owner is a moderator
if ($user && $user->id == $room->user_id) {
return true;
}
// Moderator's authentication via the extra request header
if (
($connection = $this->getConnectionFromRequest())
&& $connection->session_id === $room->session_id
&& $connection->role & Room::ROLE_MODERATOR
) {
return true;
}
return false;
}
/**
* Check if current user "owns" the specified connection.
*
* @param \App\OpenVidu\Connection $connection The connection
*
* @return bool
*/
protected function isSelfConnection(Connection $connection): bool
{
return ($conn = $this->getConnectionFromRequest())
&& $conn->id === $connection->id;
}
/**
* Get the connection object for the token in current request headers.
* It will also validate the token.
*
* @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
*/
protected function getConnectionFromRequest()
{
// Authenticate the user via the extra request header
if ($token = request()->header(self::AUTH_HEADER)) {
list($connId, ) = explode(':', base64_decode($token), 2);
if (
($connection = Connection::find($connId))
&& $connection->metadata['authToken'] === $token
) {
return $connection;
}
}
return null;
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index b1d2ab87..b045406c 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,474 +1,477 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use App\Wallet;
use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = self::walletMandate($wallet);
return response()->json($mandate);
}
/**
* Create a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => 'CHF',
'description' => \config('app.name') . ' Auto-Payment Setup',
'methodId' => $request->methodId
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Revoke the auto-payment mandate.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateDelete()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$provider = PaymentProvider::factory($wallet);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Validate an auto-payment mandate request.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
&& $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
return ['amount' => \trans('validation.minamount', ['amount' => $min])];
}
return null;
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$rules = [
'amount' => 'required|numeric',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$request = [
'type' => PaymentProvider::TYPE_ONEOFF,
'currency' => $request->currency,
'amount' => $amount,
'methodId' => $request->methodId,
'description' => \config('app.name') . ' Payment',
];
$provider = PaymentProvider::factory($wallet);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Delete a pending payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
// TODO currently unused
// public function cancel(Request $request)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $user->wallets()->first();
// $paymentId = $request->payment;
// $user_owns_payment = Payment::where('id', $paymentId)
// ->where('wallet_id', $wallet->id)
// ->exists();
// if (!$user_owns_payment) {
// return $this->errorResponse(404);
// }
// $provider = PaymentProvider::factory($wallet);
// if ($provider->cancel($wallet, $paymentId)) {
// $result = ['status' => 'success'];
// return response()->json($result);
// }
// return $this->errorResponse(404);
// }
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
- if ((bool) $wallet->getSetting('mandate_disabled')) {
+ $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
+
+ if (!empty($settings['mandate_disabled'])) {
return false;
}
- $min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100);
- $amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100);
+ $min_balance = (int) (floatval($settings['mandate_balance']) * 100);
+ $amount = (int) (floatval($settings['mandate_amount']) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => 'CHF',
'amount' => $amount,
'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => \config('app.name') . ' Recurring Payment',
];
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
+ $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
- $mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled');
+ $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
foreach (['amount', 'balance'] as $key) {
- if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
+ if (($value = $settings["mandate_{$key}"]) !== null) {
$mandate[$key] = $value;
}
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$methods = PaymentProvider::paymentMethods($wallet, $request->type);
\Log::debug("Provider methods" . var_export(json_encode($methods), true));
return response()->json($methods);
}
/**
* Check for pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
->where('type', PaymentProvider::TYPE_ONEOFF)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::STATUS_AUTHORIZED])
->exists();
return response()->json([
'status' => 'success',
'hasPending' => $exists
]);
}
/**
* List pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function payments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$result = Payment::where('wallet_id', $wallet->id)
->where('type', PaymentProvider::TYPE_ONEOFF)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::STATUS_AUTHORIZED])
->orderBy('created_at', 'desc')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
$result = $result->map(function ($item) {
$provider = PaymentProvider::factory($item->provider);
$payment = $provider->getPayment($item->id);
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->description,
'amount' => $item->amount,
'status' => $item->status,
'isCancelable' => $payment['isCancelable'],
'checkoutUrl' => $payment['checkoutUrl']
];
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
}
diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
index 4d28659f..cc7d2ac5 100644
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -1,341 +1,341 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Transaction;
use App\Wallet;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use Carbon\Carbon;
use Illuminate\Http\Request;
/**
* API\WalletsController
*/
class WalletsController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
return $this->errorResponse(404);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
return $this->errorResponse(404);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Return data of the specified wallet.
*
* @param string $id A wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function show($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->toArray();
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['notice'] = $this->getWalletNotice($wallet);
return response()->json($result);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
return $this->errorResponse(404);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param string $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
return $this->errorResponse(404);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
return $this->errorResponse(404);
}
/**
* Download a receipt in pdf format.
*
* @param string $id Wallet identifier
* @param string $receipt Receipt identifier (YYYY-MM)
*
* @return \Illuminate\Http\Response
*/
public function receiptDownload($id, $receipt)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
- return $this->errorResponse(404);
+ abort(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
abort(403);
}
list ($year, $month) = explode('-', $receipt);
if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) {
abort(404);
}
if ($receipt >= date('Y-m')) {
abort(404);
}
$params = [
'id' => sprintf('%04d-%02d', $year, $month),
'site' => \config('app.name')
];
$filename = \trans('documents.receipt-filename', $params);
$receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month);
$content = $receipt->pdfOutput();
return response($content)
->withHeaders([
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($content),
]);
}
/**
* Fetch wallet receipts list.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function receipts($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->payments()
->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident')
->where('status', PaymentProvider::STATUS_PAID)
->where('amount', '<>', 0)
->orderBy('ident', 'desc')
->get()
->whereNotIn('ident', [date('Y-m')]) // exclude current month
->pluck('ident');
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => false,
'page' => 1,
]);
}
/**
* Fetch wallet transactions.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function transactions($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$isAdmin = $this instanceof Admin\WalletsController;
if ($transaction = request()->input('transaction')) {
// Get sub-transactions for the specified transaction ID, first
// check access rights to the transaction's wallet
$transaction = $wallet->transactions()->where('id', $transaction)->first();
if (!$transaction) {
return $this->errorResponse(404);
}
$result = Transaction::where('transaction_id', $transaction->id)->get();
} else {
// Get main transactions (paged)
$result = $wallet->transactions()
// FIXME: Do we know which (type of) transaction has sub-transactions
// without the sub-query?
->selectRaw("*, (SELECT count(*) FROM transactions sub "
. "WHERE sub.transaction_id = transactions.id) AS cnt")
->whereNull('transaction_id')
->latest()
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
}
$result = $result->map(function ($item) use ($isAdmin) {
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->shortDescription(),
'amount' => $item->amount,
'hasDetails' => !empty($item->cnt),
];
if ($isAdmin && $item->user_email) {
$entry['user'] = $item->user_email;
}
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Returns human readable notice about the wallet state.
*
* @param \App\Wallet $wallet The wallet
*/
protected function getWalletNotice(Wallet $wallet): ?string
{
// there is no credit
if ($wallet->balance < 0) {
return \trans('app.wallet-notice-nocredit');
}
// the discount is 100%, no credit is needed
if ($wallet->discount && $wallet->discount->discount == 100) {
return null;
}
// the owner was created less than a month ago
if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
// but more than two weeks ago, notice of trial ending
if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) {
return \trans('app.wallet-notice-trial-end');
}
return \trans('app.wallet-notice-trial');
}
if ($until = $wallet->balanceLastsUntil()) {
if ($until->isToday()) {
return \trans('app.wallet-notice-today');
}
// Once in a while we got e.g. "3 weeks" instead of expected "4 weeks".
// It's because $until uses full seconds, but $now is more precise.
// We make sure both have the same time set.
$now = Carbon::now()->setTimeFrom($until);
$diffOptions = [
'syntax' => Carbon::DIFF_ABSOLUTE,
'parts' => 1,
];
if ($now->diff($until)->days > 31) {
$diffOptions['parts'] = 2;
}
$params = [
'date' => $until->toDateString(),
'days' => $now->diffForHumans($until, $diffOptions),
];
return \trans('app.wallet-notice-date', $params);
}
return null;
}
}
diff --git a/src/app/Jobs/PasswordResetEmail.php b/src/app/Jobs/PasswordResetEmail.php
index f4ff3f50..3d2b563b 100644
--- a/src/app/Jobs/PasswordResetEmail.php
+++ b/src/app/Jobs/PasswordResetEmail.php
@@ -1,64 +1,67 @@
<?php
namespace App\Jobs;
use App\Mail\PasswordReset;
use App\VerificationCode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class PasswordResetEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 2;
/** @var \App\VerificationCode Verification code object */
protected $code;
/** @var bool Delete the job if its models no longer exist. */
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @param \App\VerificationCode $code Verification code object
*
* @return void
*/
public function __construct(VerificationCode $code)
{
$this->code = $code;
}
/**
* Determine the time at which the job should timeout.
*
* @return \DateTime
*/
public function retryUntil()
{
// FIXME: I think it does not make sense to continue trying after 1 hour
return now()->addHours(1);
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$email = $this->code->user->getSetting('external_email');
- Mail::to($email)->send(new PasswordReset($this->code));
+ \App\Mail\Helper::sendMail(
+ new PasswordReset($this->code),
+ $this->code->user->tenant_id,
+ ['to' => $email]
+ );
}
}
diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php
index 1f0b584c..a1391b5f 100644
--- a/src/app/Jobs/PaymentEmail.php
+++ b/src/app/Jobs/PaymentEmail.php
@@ -1,118 +1,102 @@
<?php
namespace App\Jobs;
use App\Payment;
use App\Providers\PaymentProvider;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class PaymentEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 2;
/** @var int The number of seconds to wait before retrying the job. */
public $retryAfter = 10;
/** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/** @var \App\Payment A payment object */
protected $payment;
/** @var \App\User A wallet controller */
protected $controller;
/**
* Create a new job instance.
*
* @param \App\Payment $payment A payment object
* @param \App\User $controller A wallet controller
*
* @return void
*/
public function __construct(Payment $payment, User $controller = null)
{
$this->payment = $payment;
$this->controller = $controller;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$wallet = $this->payment->wallet;
if (empty($this->controller)) {
$this->controller = $wallet->owner;
}
if (empty($this->controller)) {
return;
}
if ($this->payment->status == PaymentProvider::STATUS_PAID) {
$mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller);
$label = "Success";
} elseif (
$this->payment->status == PaymentProvider::STATUS_EXPIRED
|| $this->payment->status == PaymentProvider::STATUS_FAILED
) {
$mail = new \App\Mail\PaymentFailure($this->payment, $this->controller);
$label = "Failure";
} else {
return;
}
list($to, $cc) = \App\Mail\Helper::userEmails($this->controller);
if (!empty($to)) {
- try {
- Mail::to($to)->cc($cc)->send($mail);
-
- $msg = sprintf(
- "[Payment] %s mail sent for %s (%s)",
- $label,
- $wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc))
- );
-
- \Log::info($msg);
- } catch (\Exception $e) {
- $msg = sprintf(
- "[Payment] Failed to send mail for wallet %s (%s): %s",
- $wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc)),
- $e->getMessage()
- );
-
- \Log::error($msg);
- throw $e;
- }
+ $params = [
+ 'to' => $to,
+ 'cc' => $cc,
+ 'add' => " for {$wallet->id}",
+ ];
+
+ \App\Mail\Helper::sendMail($mail, $this->controller->tenant_id, $params);
}
/*
// Send the email to all wallet controllers too
if ($wallet->owner->id == $this->controller->id) {
$this->wallet->controllers->each(function ($controller) {
self::dispatch($this->payment, $controller);
}
});
*/
}
}
diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php
index 341f3e33..a7fd82d9 100644
--- a/src/app/Jobs/PaymentMandateDisabledEmail.php
+++ b/src/app/Jobs/PaymentMandateDisabledEmail.php
@@ -1,104 +1,89 @@
<?php
namespace App\Jobs;
use App\Mail\PaymentMandateDisabled;
use App\User;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class PaymentMandateDisabledEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 2;
/** @var int The number of seconds to wait before retrying the job. */
public $retryAfter = 10;
/** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/** @var \App\Wallet A wallet object */
protected $wallet;
/** @var \App\User A wallet controller */
protected $controller;
/**
* Create a new job instance.
*
* @param \App\Wallet $wallet A wallet object
* @param \App\User $controller An email recipient (wallet controller)
*
* @return void
*/
public function __construct(Wallet $wallet, User $controller = null)
{
$this->wallet = $wallet;
$this->controller = $controller;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (empty($this->controller)) {
$this->controller = $this->wallet->owner;
}
if (empty($this->controller)) {
return;
}
$mail = new PaymentMandateDisabled($this->wallet, $this->controller);
list($to, $cc) = \App\Mail\Helper::userEmails($this->controller);
if (!empty($to)) {
- try {
- Mail::to($to)->cc($cc)->send($mail);
-
- $msg = sprintf(
- "[PaymentMandateDisabled] Sent mail for %s (%s)",
- $this->wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc))
- );
-
- \Log::info($msg);
- } catch (\Exception $e) {
- $msg = sprintf(
- "[PaymentMandateDisabled] Failed to send mail for wallet %s (%s): %s",
- $this->wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc)),
- $e->getMessage()
- );
-
- \Log::error($msg);
- throw $e;
- }
+ $params = [
+ 'to' => $to,
+ 'cc' => $cc,
+ 'add' => " for {$this->wallet->id}",
+ ];
+
+ \App\Mail\Helper::sendMail($mail, $this->controller->tenant_id, $params);
}
/*
// Send the email to all controllers too
if ($this->controller->id == $this->wallet->owner->id) {
$this->wallet->controllers->each(function ($controller) {
self::dispatch($this->wallet, $controller);
}
});
*/
}
}
diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php
index e23a9590..6a8a8e00 100644
--- a/src/app/Jobs/SignupInvitationEmail.php
+++ b/src/app/Jobs/SignupInvitationEmail.php
@@ -1,75 +1,78 @@
<?php
namespace App\Jobs;
use App\SignupInvitation;
use App\Mail\SignupInvitation as SignupInvitationMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class SignupInvitationEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 3;
/** @var bool Delete the job if its models no longer exist. */
public $deleteWhenMissingModels = true;
/** @var int The number of seconds to wait before retrying the job. */
public $retryAfter = 10;
/** @var SignupInvitation Signup invitation object */
protected $invitation;
/**
* Create a new job instance.
*
* @param SignupInvitation $invitation Invitation object
*
* @return void
*/
public function __construct(SignupInvitation $invitation)
{
$this->invitation = $invitation;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
- Mail::to($this->invitation->email)->send(new SignupInvitationMail($this->invitation));
+ \App\Mail\Helper::sendMail(
+ new SignupInvitationMail($this->invitation),
+ $this->invitation->tenant_id,
+ ['to' => $this->invitation->email]
+ );
// Update invitation status
$this->invitation->status = SignupInvitation::STATUS_SENT;
$this->invitation->save();
}
/**
* The job failed to process.
*
* @param \Exception $exception
*
* @return void
*/
public function failed(\Exception $exception)
{
if ($this->attempts() >= $this->tries) {
// Update invitation status
$this->invitation->status = SignupInvitation::STATUS_FAILED;
$this->invitation->save();
}
}
}
diff --git a/src/app/Jobs/SignupVerificationEmail.php b/src/app/Jobs/SignupVerificationEmail.php
index bf51ed2c..46516b63 100644
--- a/src/app/Jobs/SignupVerificationEmail.php
+++ b/src/app/Jobs/SignupVerificationEmail.php
@@ -1,63 +1,66 @@
<?php
namespace App\Jobs;
use App\Mail\SignupVerification;
use App\SignupCode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class SignupVerificationEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 2;
/** @var bool Delete the job if its models no longer exist. */
public $deleteWhenMissingModels = true;
/** @var SignupCode Signup verification code object */
protected $code;
/**
* Create a new job instance.
*
* @param SignupCode $code Verification code object
*
* @return void
*/
public function __construct(SignupCode $code)
{
$this->code = $code;
}
/**
* Determine the time at which the job should timeout.
*
* @return \DateTime
*/
public function retryUntil()
{
// FIXME: I think it does not make sense to continue trying after 1 hour
return now()->addHours(1);
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
- Mail::to($this->code->email)->send(new SignupVerification($this->code));
+ \App\Mail\Helper::sendMail(
+ new SignupVerification($this->code),
+ null,
+ ['to' => $this->code->email]
+ );
}
}
diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php
index f388848b..9876bfe2 100644
--- a/src/app/Jobs/WalletCheck.php
+++ b/src/app/Jobs/WalletCheck.php
@@ -1,338 +1,311 @@
<?php
namespace App\Jobs;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\Mail;
class WalletCheck implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public const THRESHOLD_DELETE = 'delete';
public const THRESHOLD_BEFORE_DELETE = 'before_delete';
public const THRESHOLD_SUSPEND = 'suspend';
public const THRESHOLD_BEFORE_SUSPEND = 'before_suspend';
public const THRESHOLD_REMINDER = 'reminder';
public const THRESHOLD_BEFORE_REMINDER = 'before_reminder';
public const THRESHOLD_INITIAL = 'initial';
/** @var int The number of seconds to wait before retrying the job. */
public $retryAfter = 10;
/** @var int How many times retry the job if it fails. */
public $tries = 5;
/** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/** @var \App\Wallet A wallet object */
protected $wallet;
/**
* Create a new job instance.
*
* @param \App\Wallet $wallet The wallet that has been charged.
*
* @return void
*/
public function __construct(Wallet $wallet)
{
$this->wallet = $wallet;
}
/**
* Execute the job.
*
* @return ?string Executed action (THRESHOLD_*)
*/
public function handle()
{
if ($this->wallet->balance >= 0) {
return null;
}
$now = Carbon::now();
// Delete the account
if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) {
$this->deleteAccount();
return self::THRESHOLD_DELETE;
}
// Warn about the upcomming account deletion
if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) {
$this->warnBeforeDelete();
return self::THRESHOLD_BEFORE_DELETE;
}
// Suspend the account
if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) {
$this->suspendAccount();
return self::THRESHOLD_SUSPEND;
}
// Try to top-up the wallet before suspending the account
if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_SUSPEND) < $now) {
PaymentsController::topUpWallet($this->wallet);
return self::THRESHOLD_BEFORE_SUSPEND;
}
// Send the second reminder
if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) {
$this->secondReminder();
return self::THRESHOLD_REMINDER;
}
// Try to top-up the wallet before the second reminder
if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_REMINDER) < $now) {
PaymentsController::topUpWallet($this->wallet);
return self::THRESHOLD_BEFORE_REMINDER;
}
// Send the initial reminder
if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) {
$this->initialReminder();
return self::THRESHOLD_INITIAL;
}
return null;
}
/**
* Send the initial reminder
*/
protected function initialReminder()
{
if ($this->wallet->getSetting('balance_warning_initial')) {
return;
}
// TODO: Should we check if the account is already suspended?
- $label = "Notification sent for";
-
- $this->sendMail(\App\Mail\NegativeBalance::class, false, $label);
+ $this->sendMail(\App\Mail\NegativeBalance::class, false);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_initial', $now);
}
/**
* Send the second reminder
*/
protected function secondReminder()
{
if ($this->wallet->getSetting('balance_warning_reminder')) {
return;
}
// TODO: Should we check if the account is already suspended?
- $label = "Reminder sent for";
-
- $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false, $label);
+ $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_reminder', $now);
}
/**
* Suspend the account (and send the warning)
*/
protected function suspendAccount()
{
if ($this->wallet->getSetting('balance_warning_suspended')) {
return;
}
// Sanity check, already deleted
if (!$this->wallet->owner) {
return;
}
// Suspend the account
$this->wallet->owner->suspend();
foreach ($this->wallet->entitlements as $entitlement) {
if (
$entitlement->entitleable_type == \App\Domain::class
|| $entitlement->entitleable_type == \App\User::class
) {
$entitlement->entitleable->suspend();
}
}
- $label = "Account suspended";
-
- $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true, $label);
+ $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_suspended', $now);
}
/**
* Send the last warning before delete
*/
protected function warnBeforeDelete()
{
if ($this->wallet->getSetting('balance_warning_before_delete')) {
return;
}
// Sanity check, already deleted
if (!$this->wallet->owner) {
return;
}
- $label = "Last warning sent for";
-
- $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true, $label);
+ $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_before_delete', $now);
}
/**
* Delete the account
*/
protected function deleteAccount()
{
// TODO: This will not work when we actually allow multiple-wallets per account
// but in this case we anyway have to change the whole thing
// and calculate summarized balance from all wallets.
// The dirty work will be done by UserObserver
if ($this->wallet->owner) {
$email = $this->wallet->owner->email;
$this->wallet->owner->delete();
\Log::info(
sprintf(
"[WalletCheck] Account deleted %s (%s)",
$this->wallet->id,
$email
)
);
}
}
/**
* Send the email
*
* @param string $class Mailable class name
* @param bool $with_external Use users's external email
- * @param ?string $log_label Log label
*/
- protected function sendMail($class, $with_external = false, $log_label = null): void
+ protected function sendMail($class, $with_external = false): void
{
// TODO: Send the email to all wallet controllers?
$mail = new $class($this->wallet, $this->wallet->owner);
list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external);
if (!empty($to) || !empty($cc)) {
- try {
- Mail::to($to)->cc($cc)->send($mail);
-
- if ($log_label) {
- $msg = sprintf(
- "[WalletCheck] %s %s (%s)",
- $log_label,
- $this->wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc)),
- );
-
- \Log::info($msg);
- }
- } catch (\Exception $e) {
- $msg = sprintf(
- "[WalletCheck] Failed to send mail for %s (%s): %s",
- $this->wallet->id,
- empty($cc) ? $to : implode(', ', array_merge([$to], $cc)),
- $e->getMessage()
- );
+ $params = [
+ 'to' => $to,
+ 'cc' => $cc,
+ 'add' => " for {$this->wallet->id}",
+ ];
- \Log::error($msg);
- throw $e;
- }
+ \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params);
}
}
/**
* Get the date-time for an action threshold. Calculated using
* the date when a wallet balance turned negative.
*
* @param \App\Wallet $wallet A wallet
* @param string $type Action type (one of self::THRESHOLD_*)
*
* @return \Carbon\Carbon The threshold date-time object
*/
public static function threshold(Wallet $wallet, string $type): ?Carbon
{
$negative_since = $wallet->getSetting('balance_negative_since');
// Migration scenario: balance<0, but no balance_negative_since set
if (!$negative_since) {
// 2h back from now, so first run can sent the initial notification
$negative_since = Carbon::now()->subHours(2);
$wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString());
} else {
$negative_since = new Carbon($negative_since);
}
$remind = 7; // remind after first X days
$suspend = 14; // suspend after next X days
$delete = 21; // delete after next X days
$warn = 3; // warn about delete on X days before delete
// Acount deletion
if ($type == self::THRESHOLD_DELETE) {
return $negative_since->addDays($delete + $suspend + $remind);
}
// Warning about the upcomming account deletion
if ($type == self::THRESHOLD_BEFORE_DELETE) {
return $negative_since->addDays($delete + $suspend + $remind - $warn);
}
// Account suspension
if ($type == self::THRESHOLD_SUSPEND) {
return $negative_since->addDays($suspend + $remind);
}
// A day before account suspension
if ($type == self::THRESHOLD_BEFORE_SUSPEND) {
return $negative_since->addDays($suspend + $remind - 1);
}
// Second notification
if ($type == self::THRESHOLD_REMINDER) {
return $negative_since->addDays($remind);
}
// A day before the second reminder
if ($type == self::THRESHOLD_BEFORE_REMINDER) {
return $negative_since->addDays($remind - 1);
}
// Initial notification
// Give it an hour so the async recurring payment has a chance to be finished
if ($type == self::THRESHOLD_INITIAL) {
return $negative_since->addHours(1);
}
return null;
}
}
diff --git a/src/app/Mail/Helper.php b/src/app/Mail/Helper.php
index fd6d0c65..dda1117b 100644
--- a/src/app/Mail/Helper.php
+++ b/src/app/Mail/Helper.php
@@ -1,61 +1,130 @@
<?php
namespace App\Mail;
+use App\Tenant;
use Illuminate\Mail\Mailable;
+use Illuminate\Support\Facades\Mail;
class Helper
{
/**
* Render the mail template.
*
* @param \Illuminate\Mail\Mailable $mail The mailable object
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function render(Mailable $mail, string $type = 'html'): string
{
// Plain text output
if ($type == 'text') {
$mail->build(); // @phpstan-ignore-line
$mailer = \Illuminate\Container\Container::getInstance()->make('mailer');
return $mailer->render(['text' => $mail->textView], $mail->buildViewData());
} elseif ($type != 'html') {
throw new \Exception("Unsupported output format");
}
// HTML output
return $mail->build()->render(); // @phpstan-ignore-line
}
+ /**
+ * Sends an email
+ *
+ * @param Mailable $mail Email content generator
+ * @param int|null $tenantId Tenant identifier
+ * @param array $params Email parameters: to, cc
+ *
+ * @throws \Exception
+ */
+ public static function sendMail(Mailable $mail, $tenantId = null, array $params = []): void
+ {
+ $class = explode("\\", get_class($mail));
+ $class = end($class);
+
+ $getRecipients = function () use ($params) {
+ $recipients = [];
+
+ // For now we do not support addresses + names, only addresses
+ foreach (['to', 'cc'] as $idx) {
+ if (!empty($params[$idx])) {
+ if (is_array($params[$idx])) {
+ $recipients = array_merge($recipients, $params[$idx]);
+ } else {
+ $recipients[] = $params[$idx];
+ }
+ }
+ }
+
+ return implode(', ', $recipients);
+ };
+
+ try {
+ if (!empty($params['to'])) {
+ $mail->to($params['to']);
+ }
+
+ if (!empty($params['cc'])) {
+ $mail->cc($params['cc']);
+ }
+
+ $fromAddress = Tenant::getConfig($tenantId, 'mail.from.address');
+ $fromName = Tenant::getConfig($tenantId, 'mail.from.name');
+ $replytoAddress = Tenant::getConfig($tenantId, 'mail.reply_to.address');
+ $replytoName = Tenant::getConfig($tenantId, 'mail.reply_to.name');
+
+ if ($fromAddress) {
+ $mail->from($fromAddress, $fromName);
+ }
+
+ if ($replytoAddress) {
+ $mail->replyTo($replytoAddress, $replytoName);
+ }
+
+ Mail::send($mail);
+
+ $msg = sprintf("[%s] Sent mail to %s%s", $class, $getRecipients(), $params['add'] ?? '');
+
+ \Log::info($msg);
+ } catch (\Exception $e) {
+ $format = "[%s] Failed to send mail to %s%s: %s";
+ $msg = sprintf($format, $class, $getRecipients(), $params['add'] ?? '', $e->getMessage());
+
+ \Log::error($msg);
+ throw $e;
+ }
+ }
+
/**
* Return user's email addresses, separately for use in To and Cc.
*
* @param \App\User $user The user
* @param bool $external Include users's external email
*
* @return array To address as the first element, Cc address(es) as the second.
*/
public static function userEmails(\App\User $user, bool $external = false): array
{
$to = $user->email;
$cc = [];
// If user has no mailbox entitlement we should not send
// the email to his main address, but use external address, if defined
if (!$user->hasSku('mailbox')) {
$to = $user->getSetting('external_email');
} elseif ($external) {
$ext_email = $user->getSetting('external_email');
if ($ext_email && $ext_email != $to) {
$cc[] = $ext_email;
}
}
return [$to, $cc];
}
}
diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php
index 85947e73..3601d4d3 100644
--- a/src/app/Mail/NegativeBalance.php
+++ b/src/app/Mail/NegativeBalance.php
@@ -1,77 +1,81 @@
<?php
namespace App\Mail;
+use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class NegativeBalance extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Wallet A wallet with a negative balance */
protected $wallet;
/** @var \App\User A wallet controller to whom the email is being sent */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]);
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
+
+ $subject = \trans('mail.negativebalance-subject', ['site' => $appName]);
$this->view('emails.html.negative_balance')
->text('emails.plain.negative_balance')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
'username' => $this->user->name(true),
- 'supportUrl' => \config('app.support_url'),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
+ 'supportUrl' => $supportUrl,
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/NegativeBalanceBeforeDelete.php b/src/app/Mail/NegativeBalanceBeforeDelete.php
index 3e826207..1a9e7f56 100644
--- a/src/app/Mail/NegativeBalanceBeforeDelete.php
+++ b/src/app/Mail/NegativeBalanceBeforeDelete.php
@@ -1,81 +1,84 @@
<?php
namespace App\Mail;
use App\Jobs\WalletCheck;
+use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class NegativeBalanceBeforeDelete extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Wallet A wallet with a negative balance */
protected $wallet;
/** @var \App\User A wallet controller to whom the email is being sent */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE);
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => $appName]);
$this->view('emails.html.negative_balance_before_delete')
->text('emails.plain.negative_balance_before_delete')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
'username' => $this->user->name(true),
- 'supportUrl' => \config('app.support_url'),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
+ 'supportUrl' => $supportUrl,
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
'date' => $threshold->toDateString(),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/NegativeBalanceReminder.php b/src/app/Mail/NegativeBalanceReminder.php
index 41fad6ac..f6455c00 100644
--- a/src/app/Mail/NegativeBalanceReminder.php
+++ b/src/app/Mail/NegativeBalanceReminder.php
@@ -1,81 +1,84 @@
<?php
namespace App\Mail;
use App\Jobs\WalletCheck;
+use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class NegativeBalanceReminder extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Wallet A wallet with a negative balance */
protected $wallet;
/** @var \App\User A wallet controller to whom the email is being sent */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_SUSPEND);
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.negativebalancereminder-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.negativebalancereminder-subject', ['site' => $appName]);
$this->view('emails.html.negative_balance_reminder')
->text('emails.plain.negative_balance_reminder')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
'username' => $this->user->name(true),
- 'supportUrl' => \config('app.support_url'),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
+ 'supportUrl' => $supportUrl,
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
'date' => $threshold->toDateString(),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/NegativeBalanceSuspended.php b/src/app/Mail/NegativeBalanceSuspended.php
index 3f88bcd1..63f69145 100644
--- a/src/app/Mail/NegativeBalanceSuspended.php
+++ b/src/app/Mail/NegativeBalanceSuspended.php
@@ -1,81 +1,84 @@
<?php
namespace App\Mail;
use App\Jobs\WalletCheck;
+use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class NegativeBalanceSuspended extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Wallet A wallet with a negative balance */
protected $wallet;
/** @var \App\User A wallet controller to whom the email is being sent */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE);
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.negativebalancesuspended-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.negativebalancesuspended-subject', ['site' => $appName]);
$this->view('emails.html.negative_balance_suspended')
->text('emails.plain.negative_balance_suspended')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
'username' => $this->user->name(true),
- 'supportUrl' => \config('app.support_url'),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
+ 'supportUrl' => $supportUrl,
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
'date' => $threshold->toDateString(),
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User();
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php
index 976c259e..889301be 100644
--- a/src/app/Mail/PasswordReset.php
+++ b/src/app/Mail/PasswordReset.php
@@ -1,81 +1,86 @@
<?php
namespace App\Mail;
+use App\Tenant;
use App\User;
use App\Utils;
use App\VerificationCode;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class PasswordReset extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\VerificationCode A verification code object */
protected $code;
/**
* Create a new message instance.
*
* @param \App\VerificationCode $code A verification code object
*
* @return void
*/
public function __construct(VerificationCode $code)
{
$this->code = $code;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
+ $appName = Tenant::getConfig($this->code->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->code->user->tenant_id, 'app.support_url');
+
$href = Utils::serviceUrl(
- sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code)
+ sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code),
+ $this->code->user->tenant_id
);
$this->view('emails.html.password_reset')
->text('emails.plain.password_reset')
- ->subject(__('mail.passwordreset-subject', ['site' => \config('app.name')]))
+ ->subject(\trans('mail.passwordreset-subject', ['site' => $appName]))
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'code' => $this->code->code,
'short_code' => $this->code->short_code,
'link' => sprintf('<a href="%s">%s</a>', $href, $href),
'username' => $this->code->user->name(true)
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$code = new VerificationCode([
'code' => Str::random(VerificationCode::CODE_LENGTH),
'short_code' => VerificationCode::generateShortCode(),
]);
$code->user = new User([
'email' => 'test@' . \config('app.domain'),
]);
$mail = new self($code);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/PaymentFailure.php b/src/app/Mail/PaymentFailure.php
index 9bb8e700..f4568b6d 100644
--- a/src/app/Mail/PaymentFailure.php
+++ b/src/app/Mail/PaymentFailure.php
@@ -1,81 +1,83 @@
<?php
namespace App\Mail;
use App\Payment;
+use App\Tenant;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class PaymentFailure extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Payment A payment operation */
protected $payment;
/** @var \App\User A wallet controller to whom the email is being send */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Payment $payment A payment operation
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Payment $payment, User $user)
{
$this->payment = $payment;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $user = $this->user;
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.paymentfailure-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.paymentfailure-subject', ['site' => $appName]);
$this->view('emails.html.payment_failure')
->text('emails.plain.payment_failure')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
- 'username' => $user->name(true),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
- 'supportUrl' => \config('app.support_url'),
+ 'username' => $this->user->name(true),
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
+ 'supportUrl' => $supportUrl,
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'mail'): string
{
$payment = new Payment();
$user = new User([
'email' => 'test@' . \config('app.domain'),
]);
$mail = new self($payment, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/PaymentMandateDisabled.php b/src/app/Mail/PaymentMandateDisabled.php
index a5e5fccc..288cee5a 100644
--- a/src/app/Mail/PaymentMandateDisabled.php
+++ b/src/app/Mail/PaymentMandateDisabled.php
@@ -1,81 +1,83 @@
<?php
namespace App\Mail;
+use App\Tenant;
use App\User;
use App\Utils;
use App\Wallet;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class PaymentMandateDisabled extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Wallet A wallet for which the mandate has been disabled */
protected $wallet;
/** @var \App\User A wallet controller to whom the email is being send */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Wallet $wallet A wallet that has been charged
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Wallet $wallet, User $user)
{
$this->wallet = $wallet;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $user = $this->user;
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => $appName]);
$this->view('emails.html.payment_mandate_disabled')
->text('emails.plain.payment_mandate_disabled')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
- 'username' => $user->name(true),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
- 'supportUrl' => \config('app.support_url'),
+ 'username' => $this->user->name(true),
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
+ 'supportUrl' => $supportUrl,
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$user = new User([
'email' => 'test@' . \config('app.domain'),
]);
$mail = new self($wallet, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/PaymentSuccess.php b/src/app/Mail/PaymentSuccess.php
index c211e73c..189db37a 100644
--- a/src/app/Mail/PaymentSuccess.php
+++ b/src/app/Mail/PaymentSuccess.php
@@ -1,81 +1,83 @@
<?php
namespace App\Mail;
use App\Payment;
+use App\Tenant;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class PaymentSuccess extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\Payment A payment operation */
protected $payment;
/** @var \App\User A wallet controller to whom the email is being send */
protected $user;
/**
* Create a new message instance.
*
* @param \App\Payment $payment A payment operation
* @param \App\User $user An email recipient
*
* @return void
*/
public function __construct(Payment $payment, User $user)
{
$this->payment = $payment;
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $user = $this->user;
+ $appName = Tenant::getConfig($this->user->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url');
- $subject = \trans('mail.paymentsuccess-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.paymentsuccess-subject', ['site' => $appName]);
$this->view('emails.html.payment_success')
->text('emails.plain.payment_success')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
- 'username' => $user->name(true),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
- 'supportUrl' => \config('app.support_url'),
+ 'username' => $this->user->name(true),
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id),
+ 'supportUrl' => $supportUrl,
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$payment = new Payment();
$user = new User([
'email' => 'test@' . \config('app.domain'),
]);
$mail = new self($payment, $user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/SignupInvitation.php b/src/app/Mail/SignupInvitation.php
index 1bda7b2f..217ad34e 100644
--- a/src/app/Mail/SignupInvitation.php
+++ b/src/app/Mail/SignupInvitation.php
@@ -1,72 +1,75 @@
<?php
namespace App\Mail;
use App\SignupInvitation as SI;
+use App\Tenant;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class SignupInvitation extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\SignupInvitation A signup invitation object */
protected $invitation;
/**
* Create a new message instance.
*
* @param \App\SignupInvitation $invitation A signup invitation object
*
* @return void
*/
public function __construct(SI $invitation)
{
$this->invitation = $invitation;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id);
+ $appName = Tenant::getConfig($this->invitation->tenant_id, 'app.name');
+
+ $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id, $this->invitation->tenant_id);
$this->view('emails.html.signup_invitation')
->text('emails.plain.signup_invitation')
- ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')]))
+ ->subject(\trans('mail.signupinvitation-subject', ['site' => $appName]))
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'href' => $href,
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$invitation = new SI([
'email' => 'test@external.org',
]);
$invitation->id = Utils::uuidStr();
$mail = new self($invitation);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/SignupVerification.php b/src/app/Mail/SignupVerification.php
index e8847b88..be3dd9df 100644
--- a/src/app/Mail/SignupVerification.php
+++ b/src/app/Mail/SignupVerification.php
@@ -1,85 +1,85 @@
<?php
namespace App\Mail;
use App\SignupCode;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class SignupVerification extends Mailable
{
use Queueable;
use SerializesModels;
/** @var SignupCode A signup verification code object */
protected $code;
/**
* Create a new message instance.
*
* @param SignupCode $code A signup verification code object
*
* @return void
*/
public function __construct(SignupCode $code)
{
$this->code = $code;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$href = Utils::serviceUrl(
sprintf('/signup/%s-%s', $this->code->short_code, $this->code->code)
);
$username = $this->code->first_name ?? '';
if (!empty($this->code->last_name)) {
$username .= ' ' . $this->code->last_name;
}
$username = trim($username);
$this->view('emails.html.signup_code')
->text('emails.plain.signup_code')
- ->subject(__('mail.signupcode-subject', ['site' => \config('app.name')]))
+ ->subject(\trans('mail.signupcode-subject', ['site' => \config('app.name')]))
->with([
'site' => \config('app.name'),
'username' => $username ?: 'User',
'code' => $this->code->code,
'short_code' => $this->code->short_code,
'href' => $href,
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$code = new SignupCode([
'code' => Str::random(SignupCode::CODE_LENGTH),
'short_code' => SignupCode::generateShortCode(),
'email' => 'test@' . \config('app.domain'),
'first_name' => 'Firstname',
'last_name' => 'Lastname',
]);
$mail = new self($code);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php
index 4ffb1803..e1d271c7 100644
--- a/src/app/Mail/SuspendedDebtor.php
+++ b/src/app/Mail/SuspendedDebtor.php
@@ -1,83 +1,86 @@
<?php
namespace App\Mail;
+use App\Tenant;
use App\User;
use App\Utils;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class SuspendedDebtor extends Mailable
{
use Queueable;
use SerializesModels;
/** @var \App\User A suspended user (account) */
protected $account;
/**
* Create a new message instance.
*
* @param \App\User $account A suspended user (account)
*
* @return void
*/
public function __construct(User $account)
{
$this->account = $account;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
- $user = $this->account;
+ $appName = Tenant::getConfig($this->account->tenant_id, 'app.name');
+ $supportUrl = Tenant::getConfig($this->account->tenant_id, 'app.support_url');
+ $cancelUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_delete');
- $subject = \trans('mail.suspendeddebtor-subject', ['site' => \config('app.name')]);
+ $subject = \trans('mail.suspendeddebtor-subject', ['site' => $appName]);
$moreInfoHtml = null;
$moreInfoText = null;
- if ($moreInfoUrl = \config('app.kb.account_suspended')) {
+ if ($moreInfoUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_suspended')) {
$moreInfoHtml = \trans('mail.more-info-html', ['href' => $moreInfoUrl]);
$moreInfoText = \trans('mail.more-info-text', ['href' => $moreInfoUrl]);
}
$this->view('emails.html.suspended_debtor')
->text('emails.plain.suspended_debtor')
->subject($subject)
->with([
- 'site' => \config('app.name'),
+ 'site' => $appName,
'subject' => $subject,
- 'username' => $user->name(true),
- 'cancelUrl' => \config('app.kb.account_delete'),
- 'supportUrl' => \config('app.support_url'),
- 'walletUrl' => Utils::serviceUrl('/wallet'),
+ 'username' => $this->account->name(true),
+ 'cancelUrl' => $cancelUrl,
+ 'supportUrl' => $supportUrl,
+ 'walletUrl' => Utils::serviceUrl('/wallet', $this->account->tenant_id),
'moreInfoHtml' => $moreInfoHtml,
'moreInfoText' => $moreInfoText,
'days' => 14 // TODO: Configurable
]);
return $this;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'text')
*
* @return string HTML or Plain Text output
*/
public static function fakeRender(string $type = 'html'): string
{
$user = new User();
$mail = new self($user);
return Helper::render($mail, $type);
}
}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index 8ae94ddd..5bbf2933 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,158 +1,145 @@
<?php
namespace App\Providers;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
\App\Discount::observe(\App\Observers\DiscountObserver::class);
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\Group::observe(\App\Observers\GroupObserver::class);
\App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class);
\App\Package::observe(\App\Observers\PackageObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\Plan::observe(\App\Observers\PlanObserver::class);
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
\App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class);
\App\Sku::observe(\App\Observers\SkuObserver::class);
\App\Transaction::observe(\App\Observers\TransactionObserver::class);
\App\User::observe(\App\Observers\UserObserver::class);
\App\UserAlias::observe(\App\Observers\UserAliasObserver::class);
\App\UserSetting::observe(\App\Observers\UserSettingObserver::class);
\App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class);
\App\Wallet::observe(\App\Observers\WalletObserver::class);
Schema::defaultStringLength(191);
// Log SQL queries in debug mode
if (\config('app.debug')) {
DB::listen(function ($query) {
\Log::debug(
sprintf(
'[SQL] %s [%s]: %.4f sec.',
$query->sql,
implode(', ', $query->bindings),
$query->time / 1000
)
);
});
}
// Register some template helpers
Blade::directive(
'theme_asset',
function ($path) {
$path = trim($path, '/\'"');
return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
}
);
Builder::macro(
'withEnvTenantContext',
function (string $table = null) {
$tenantId = \config('app.tenant_id');
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withObjectTenantContext',
function (object $object, string $table = null) {
- // backend artisan cli
- if (app()->runningInConsole()) {
- /** @var Builder $this */
- return $this->where(($table ? "$table." : "") . "tenant_id", $object->tenant_id);
- }
-
- $subject = auth()->user();
-
- if ($subject->role == "admin") {
- /** @var Builder $this */
- return $this->where(($table ? "$table." : "") . "tenant_id", $object->tenant_id);
- }
-
- $tenantId = $subject->tenant_id;
+ $tenantId = $object->tenant_id;
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withSubjectTenantContext',
function (string $table = null) {
if ($user = auth()->user()) {
$tenantId = $user->tenant_id;
} else {
$tenantId = \config('app.tenant_id');
}
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
// Query builder 'whereLike' mocro
Builder::macro(
'whereLike',
function (string $column, string $search, int $mode = 0) {
$search = addcslashes($search, '%_');
switch ($mode) {
case 2:
$search .= '%';
break;
case 1:
$search = '%' . $search;
break;
default:
$search = '%' . $search . '%';
}
/** @var Builder $this */
return $this->where($column, 'like', $search);
}
);
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index 9b8101ae..1048b473 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,622 +1,621 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use Illuminate\Support\Facades\DB;
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\Types;
class Mollie extends \App\Providers\PaymentProvider
{
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'<a href="https://www.mollie.com/dashboard/customers/%s" target="_blank">%s</a>',
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
if (!isset($payment['amount'])) {
$payment['amount'] = 0;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => self::redirectUrl(),
'locale' => 'en_US',
'method' => $payment['methodId']
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$payment['type'] = self::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method'),
'methodId' => $mandate->method
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required (note that JPK and ISK don't require decimals,
// but we're not using them currently)
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'redirectUrl' => self::redirectUrl() // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Cancel a pending payment.
*
* @param \App\Wallet $wallet The wallet
* @param string $paymentId Payment Id
*
* @return bool True on success, False on failure
*/
public function cancel(Wallet $wallet, $paymentId): bool
{
$response = mollie()->payments()->delete($paymentId);
$db_payment = Payment::find($paymentId);
$db_payment->status = $response->status;
$db_payment->save();
return true;
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
return null;
}
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
if (empty($mollie_payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$credit = true;
$notify = $payment->type == self::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => self::TYPE_REFUND,
'currency' => $refund->amount->currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => self::TYPE_CHARGEBACK,
'currency' => $chargeback->amount->currency
];
}
}
}
// In case there were multiple auto-payment setup requests (e.g. caused by a double
// form submission) we end up with multiple payment records and mollie_mandate_id
// pointing to the one from the last payment not the successful one.
// We make sure to use mandate id from the successful "first" payment.
if (
$payment->type == self::TYPE_MANDATE
&& $mollie_payment->mandateId
&& $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST
) {
$payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId);
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == self::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$this->storeRefund($payment->wallet, $refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
- $customer_id = $wallet->getSetting('mollie_id');
- $mandate_id = $wallet->getSetting('mollie_mandate_id');
+ $settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']);
// Get the manadate reference we already have
- if ($customer_id && $mandate_id) {
+ if ($settings['mollie_id'] && $settings['mollie_mandate_id']) {
try {
- return mollie()->mandates()->getForId($customer_id, $mandate_id);
+ return mollie()->mandates()->getForId($settings['mollie_id'], $settings['mollie_mandate_id']);
} catch (ApiException $e) {
// FIXME: What about 404?
if ($e->getCode() == 410) {
// The mandate is gone, remove the reference
$wallet->setSetting('mollie_mandate_id', null);
return null;
}
// TODO: Maybe we shouldn't always throw? It make sense in the job
// but for example when we're just fetching wallet info...
throw $e;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
case self::METHOD_CREDITCARD:
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
case self::METHOD_PAYPAL:
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods($type): array
{
$providerMethods = array_merge(
// Fallback to EUR methods (later provider methods will override earlier ones)
(array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => 'EUR'
]
]
),
// Prefer CHF methods
(array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => 'CHF'
]
]
)
);
$availableMethods = [];
foreach ($providerMethods as $method) {
$availableMethods[$method->id] = [
'id' => $method->id,
'name' => $method->description,
'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
'currency' => $method->minimumAmount->currency,
'exchangeRate' => \App\Utils::exchangeRate('CHF', $method->minimumAmount->currency)
];
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
$payment = mollie()->payments()->get($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => $payment->isCancelable,
'checkoutUrl' => $payment->getCheckoutUrl()
];
}
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index 38e18746..dc21e88b 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,380 +1,382 @@
<?php
namespace App\Providers;
use App\Transaction;
use App\Payment;
use App\Wallet;
use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
public const STATUS_OPEN = 'open';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_EXPIRED = 'expired';
public const STATUS_FAILED = 'failed';
public const STATUS_PAID = 'paid';
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
public const TYPE_MANDATE = 'mandate';
public const TYPE_REFUND = 'refund';
public const TYPE_CHARGEBACK = 'chargeback';
public const METHOD_CREDITCARD = 'creditcard';
public const METHOD_PAYPAL = 'paypal';
public const METHOD_BANKTRANSFER = 'banktransfer';
public const METHOD_DIRECTDEBIT = 'directdebit';
public const PROVIDER_MOLLIE = 'mollie';
public const PROVIDER_STRIPE = 'stripe';
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
private static $paymentMethodIcons = [
self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university']
];
/**
* Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
* @return string The name of the provider
*/
private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
- if ($provider_or_wallet->getSetting('stripe_id')) {
+ $settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']);
+
+ if ($settings['stripe_id']) {
$provider = self::PROVIDER_STRIPE;
- } elseif ($provider_or_wallet->getSetting('mollie_id')) {
+ } elseif ($settings['mollie_id']) {
$provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
return \strtolower($provider);
}
/**
* Factory method
*
* @param \App\Wallet|string|null $provider_or_wallet
*/
public static function factory($provider_or_wallet = null)
{
switch (self::providerName($provider_or_wallet)) {
case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
default:
throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
* - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
$db_payment->description = $payment['description'] ?? '';
$db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
$db_payment->amount = $payment['amount'] ?? 0;
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $wallet_id;
$db_payment->provider = $this->name();
$db_payment->currency = $payment['currency'];
$db_payment->currency_amount = $payment['currency_amount'];
$db_payment->save();
return $db_payment;
}
/**
* Convert a value from $sourceCurrency to $targetCurrency
*
* @param int $amount Amount in cents of $sourceCurrency
* @param string $sourceCurrency Currency from which to convert
* @param string $targetCurrency Currency to convert to
*
* @return int Exchanged amount in cents of $targetCurrency
*/
protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
{
return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency)));
}
/**
* Deduct an amount of pecunia from the wallet.
* Creates a payment and transaction records for the refund/chargeback operation.
*
* @param \App\Wallet $wallet A wallet object
* @param array $refund A refund or chargeback data (id, type, amount, description)
*
* @return void
*/
protected function storeRefund(Wallet $wallet, array $refund): void
{
if (empty($refund) || empty($refund['amount'])) {
return;
}
// Preserve originally refunded amount
$refund['currency_amount'] = $refund['amount'] * -1;
// Convert amount to wallet currency
// TODO We should possibly be using the same exchange rate as for the original payment?
$amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency);
$wallet->balance -= $amount;
$wallet->save();
if ($refund['type'] == self::TYPE_CHARGEBACK) {
$transaction_type = Transaction::WALLET_CHARGEBACK;
} else {
$transaction_type = Transaction::WALLET_REFUND;
}
Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => $transaction_type,
'amount' => $amount * -1,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
$refund['amount'] = -1 * $amount;
// FIXME: Refunds/chargebacks are out of the reseller comissioning for now
$this->storePayment($refund, $wallet->id);
}
/**
* List supported payment methods from this provider
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
abstract public function providerPaymentMethods($type): array;
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
abstract public function getPayment($paymentId): array;
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
protected static function paymentMethodsWhitelist($type): array
{
$methods = [];
switch ($type) {
case self::TYPE_ONEOFF:
$methods = explode(',', \config('app.payment.methods_oneoff'));
break;
case PaymentProvider::TYPE_RECURRING:
$methods = explode(',', \config('app.payment.methods_recurring'));
break;
default:
\Log::error("Unknown payment type: " . $type);
}
$methods = array_map('strtolower', array_map('trim', $methods));
return $methods;
}
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
private static function applyMethodWhitelist($type, $availableMethods): array
{
$methods = [];
// Use only whitelisted methods, and apply values from whitelist (overriding the backend)
$whitelistMethods = self::paymentMethodsWhitelist($type);
foreach ($whitelistMethods as $id) {
if (array_key_exists($id, $availableMethods)) {
$method = $availableMethods[$id];
$method['icon'] = self::$paymentMethodIcons[$id];
$methods[] = $method;
}
}
return $methods;
}
/**
* List supported payment methods for $wallet
*
* @param \App\Wallet $wallet The wallet
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public static function paymentMethods(Wallet $wallet, $type): array
{
$providerName = self::providerName($wallet);
$cacheKey = "methods-" . $providerName . '-' . $type;
if ($methods = Cache::get($cacheKey)) {
\Log::debug("Using payment method cache" . var_export($methods, true));
return $methods;
}
$provider = PaymentProvider::factory($providerName);
$methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type));
\Log::debug("Loaded payment methods" . var_export($methods, true));
Cache::put($cacheKey, $methods, now()->addHours(1));
return $methods;
}
/**
* Returns the full URL for the wallet page, used when returning from an external payment page.
* Depending on the request origin it will return a URL for the User or Reseller UI.
*
* @return string The redirect URL
*/
public static function redirectUrl(): string
{
$url = \App\Utils::serviceUrl('/wallet');
$domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
if (strpos($domain, 'reseller') === 0) {
$url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
}
return $url;
}
}
diff --git a/src/app/Tenant.php b/src/app/Tenant.php
index c233f671..76a46e68 100644
--- a/src/app/Tenant.php
+++ b/src/app/Tenant.php
@@ -1,53 +1,104 @@
<?php
namespace App;
+use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
/**
* The eloquent definition of a Tenant.
*
* @property int $id
* @property string $title
*/
class Tenant extends Model
{
+ use SettingsTrait;
+
protected $fillable = [
'id',
'title',
];
protected $keyType = 'bigint';
+ /**
+ * Utility method to get tenant-specific system setting.
+ * If the setting is not specified for the tenant a system-wide value will be returned.
+ *
+ * @param int $tenantId Tenant identifier
+ * @param string $key Setting name
+ *
+ * @return mixed Setting value
+ */
+ public static function getConfig($tenantId, string $key)
+ {
+ // Cache the tenant instance in memory
+ static $tenant;
+
+ if (empty($tenant) || $tenant->id != $tenantId) {
+ $tenant = null;
+ if ($tenantId) {
+ $tenant = self::findOrFail($tenantId);
+ }
+ }
+
+ // Supported options (TODO: document this somewhere):
+ // - app.name (tenants.title will be returned)
+ // - app.public_url and app.url
+ // - app.support_url
+ // - mail.from.address and mail.from.name
+ // - mail.reply_to.address and mail.reply_to.name
+ // - app.kb.account_delete and app.kb.account_suspended
+
+ if ($key == 'app.name') {
+ return $tenant ? $tenant->title : \config($key);
+ }
+
+ $value = $tenant ? $tenant->getSetting($key) : null;
+
+ return $value !== null ? $value : \config($key);
+ }
+
/**
* Discounts assigned to this tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function discounts()
{
return $this->hasMany('App\Discount');
}
+ /**
+ * Any (additional) settings of this tenant.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\TenantSetting');
+ }
+
/**
* SignupInvitations assigned to this tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function signupInvitations()
{
return $this->hasMany('App\SignupInvitation');
}
/*
* Returns the wallet of the tanant (reseller's wallet).
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first();
return $user ? $user->wallets->first() : null;
}
}
diff --git a/src/app/TenantSetting.php b/src/app/TenantSetting.php
new file mode 100644
index 00000000..0baac623
--- /dev/null
+++ b/src/app/TenantSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Tenant.
+ *
+ * @property int $id
+ * @property string $key
+ * @property int $tenant_id
+ * @property string $value
+ */
+class TenantSetting extends Model
+{
+ protected $fillable = [
+ 'tenant_id', 'key', 'value'
+ ];
+
+ /**
+ * The tenant to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('\App\Tenant', 'tenant_id', 'id');
+ }
+}
diff --git a/src/app/Traits/SettingsTrait.php b/src/app/Traits/SettingsTrait.php
index 9986aaa6..e8b2e36d 100644
--- a/src/app/Traits/SettingsTrait.php
+++ b/src/app/Traits/SettingsTrait.php
@@ -1,144 +1,134 @@
<?php
namespace App\Traits;
-use Illuminate\Support\Facades\Cache;
-
trait SettingsTrait
{
/**
* Obtain the value for a setting.
*
* Example Usage:
*
* ```php
* $user = User::firstOrCreate(['email' => 'some@other.erg']);
* $locale = $user->getSetting('locale');
* ```
*
- * @param string $key Setting name
+ * @param string $key Setting name
+ * @param mixed $default Default value, to be used if not found
*
* @return string|null Setting value
*/
- public function getSetting(string $key)
+ public function getSetting(string $key, $default = null)
+ {
+ $setting = $this->settings()->where('key', $key)->first();
+
+ return $setting ? $setting->value : $default;
+ }
+
+ /**
+ * Obtain the values for many settings in one go (for better performance).
+ *
+ * @param array $keys Setting names
+ *
+ * @return array Setting key=value hash, includes also requested but non-existing settings
+ */
+ public function getSettings(array $keys): array
{
- $settings = $this->getCache();
+ $settings = [];
- if (!array_key_exists($key, $settings)) {
- return null;
+ foreach ($keys as $key) {
+ $settings[$key] = null;
}
- $value = $settings[$key];
+ $this->settings()->whereIn('key', $keys)->get()
+ ->each(function ($setting) use (&$settings) {
+ $settings[$setting->key] = $setting->value;
+ });
- return empty($value) ? null : $value;
+ return $settings;
}
/**
* Remove a setting.
*
* Example Usage:
*
* ```php
* $user = User::firstOrCreate(['email' => 'some@other.erg']);
* $user->removeSetting('locale');
* ```
*
* @param string $key Setting name
*
* @return void
*/
public function removeSetting(string $key): void
{
$this->setSetting($key, null);
}
/**
* Create or update a setting.
*
* Example Usage:
*
* ```php
* $user = User::firstOrCreate(['email' => 'some@other.erg']);
* $user->setSetting('locale', 'en');
* ```
*
* @param string $key Setting name
* @param string|null $value The new value for the setting.
*
* @return void
*/
public function setSetting(string $key, $value): void
{
$this->storeSetting($key, $value);
- $this->setCache();
}
/**
* Create or update multiple settings in one fell swoop.
*
* Example Usage:
*
* ```php
* $user = User::firstOrCreate(['email' => 'some@other.erg']);
* $user->setSettings(['locale', 'en', 'country' => 'GB']);
* ```
*
* @param array $data An associative array of key value pairs.
*
* @return void
*/
public function setSettings(array $data = []): void
{
foreach ($data as $key => $value) {
$this->storeSetting($key, $value);
}
-
- $this->setCache();
}
+ /**
+ * Create or update a setting.
+ *
+ * @param string $key Setting name
+ * @param string|null $value The new value for the setting.
+ *
+ * @return void
+ */
private function storeSetting(string $key, $value): void
{
if ($value === null || $value === '') {
// Note: We're selecting the record first, so observers can act
if ($setting = $this->settings()->where('key', $key)->first()) {
$setting->delete();
}
} else {
$this->settings()->updateOrCreate(
['key' => $key],
['value' => $value]
);
}
}
-
- private function getCache()
- {
- $model = \strtolower(get_class($this));
-
- if (Cache::has("{$model}_settings_{$this->id}")) {
- return Cache::get("{$model}_settings_{$this->id}");
- }
-
- return $this->setCache();
- }
-
- private function setCache()
- {
- $model = \strtolower(get_class($this));
-
- if (Cache::has("{$model}_settings_{$this->id}")) {
- Cache::forget("{$model}_settings_{$this->id}");
- }
-
- $cached = [];
- foreach ($this->settings()->get() as $entry) {
- if ($entry->value !== null && $entry->value !== '') {
- $cached[$entry->key] = $entry->value;
- }
- }
-
- Cache::forever("{$model}_settings_{$this->id}", $cached);
-
- return $this->getCache();
- }
}
diff --git a/src/app/User.php b/src/app/User.php
index 96dd0262..a94d2d22 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,800 +1,799 @@
<?php
namespace App;
use App\Entitlement;
use App\UserAlias;
use App\Sku;
use App\Traits\UserConfigTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Tymon\JWTAuth\Contracts\JWTSubject;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable implements JWTSubject
{
use NullableFields;
use UserConfigTrait;
use UserAliasesTrait;
use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// change the default primary key type
public $incrementing = false;
protected $keyType = 'bigint';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'role'
];
protected $nullable = [
'password',
'password_ldap'
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
'App\Wallet', // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Email aliases of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function aliases()
{
return $this->hasMany('App\UserAlias', 'user_id');
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
$wallet_id = $this->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
\App\Entitlement::create(
[
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $user->id,
'entitleable_type' => User::class
]
);
}
}
return $user;
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Assign a Sku to a user.
*
* @param \App\Sku $sku The sku to assign.
* @param int $count Count of entitlements to add
*
* @return \App\User Self
* @throws \Exception
*/
public function assignSku(Sku $sku, int $count = 1): User
{
// TODO: I guess wallet could be parametrized in future
$wallet = $this->wallet();
$exists = $this->entitlements()->where('sku_id', $sku->id)->count();
while ($count > 0) {
\App\Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => User::class
]);
$exists++;
$count--;
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $this->wallets->contains($wallet) || $this->accounts->contains($wallet);
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Return the \App\Domain for this user.
*
* @return \App\Domain|null
*/
public function domain()
{
list($local, $domainName) = explode('@', $this->email);
$domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first();
return $domain;
}
/**
* List the domains to which this user is entitled.
* Note: Active public domains are also returned (for the user tenant).
*
* @return Domain[] List of Domain objects
*/
public function domains(): array
{
if ($this->tenant_id) {
$domains = Domain::where('tenant_id', $this->tenant_id);
} else {
$domains = Domain::withEnvTenantContext();
}
$domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE))
->get()
->all();
foreach ($this->wallets as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domains[] = $entitlement->entitleable;
}
}
foreach ($this->accounts as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domains[] = $entitlement->entitleable;
}
}
return $domains;
}
/**
* The user entitlement.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
/**
* Entitlements for this user.
*
* Note that these are entitlements that apply to the user account, and not entitlements that
* this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement', 'entitleable_id', 'id')
->where('entitleable_type', User::class);
}
/**
* Find whether an email address exists as a user (including deleted users).
*
* @param string $email Email address
* @param bool $return_user Return User instance instead of boolean
*
* @return \App\User|bool True or User model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_user = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$user = self::withTrashed()->where('email', $email)->first();
if ($user) {
return $return_user ? $user : true;
}
return false;
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return Group::select(['groups.*', 'entitlements.wallet_id'])
->distinct()
->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', Group::class);
}
/**
* Check if user has an entitlement for the specified SKU.
*
* @param string $title The SKU title
*
* @return bool True if specified SKU entitlement exists
*/
public function hasSku(string $title): bool
{
$sku = Sku::withObjectTenantContext($this)->where('title', $title)->first();
if (!$sku) {
return false;
}
return $this->entitlements()->where('sku_id', $sku->id)->count() > 0;
}
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this domain is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isImapReady(): bool
{
return ($this->status & self::STATUS_IMAP_READY) > 0;
}
/**
* Returns whether this user is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
* Returns whether this user is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
* Returns whether this domain is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
- $firstname = $this->getSetting('first_name');
- $lastname = $this->getSetting('last_name');
+ $settings = $this->getSettings(['first_name', 'last_name']);
- $name = trim($firstname . ' ' . $lastname);
+ $name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
- return \config('app.name') . ' User';
+ return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Remove a number of entitlements for the SKU.
*
* @param \App\Sku $sku The SKU
* @param int $count The number of entitlements to remove
*
* @return User Self
*/
public function removeSku(Sku $sku, int $count = 1): User
{
$entitlements = $this->entitlements()
->where('sku_id', $sku->id)
->orderBy('cost', 'desc')
->orderBy('created_at')
->get();
$entitlements_count = count($entitlements);
foreach ($entitlements as $entitlement) {
if ($entitlements_count <= $sku->units_free) {
continue;
}
if ($count > 0) {
$entitlement->delete();
$entitlements_count--;
$count--;
}
}
return $this;
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Any (additional) properties of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\UserSetting', 'user_id');
}
/**
* Suspend this domain.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= User::STATUS_SUSPENDED;
$this->save();
}
/**
* The tenant for this user account.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function tenant()
{
return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
}
/**
* Unsuspend this domain.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= User::STATUS_SUSPENDED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return $this->select(['users.*', 'entitlements.wallet_id'])
->distinct()
->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', User::class);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Returns the wallet by which the user is controlled
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first();
// TODO: No entitlement should not happen, but in tests we have
// such cases, so we fallback to the user's wallet in this case
return $entitlement ? $entitlement->wallet : $this->wallets()->first();
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany('App\Wallet');
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 472840ef..b3896f80 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,566 +1,568 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Facades\Cache;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @return string
*/
public static function countryForIP($ip)
{
if (strpos($ip, ':') === false) {
$query = "
SELECT country FROM ip4nets
WHERE INET_ATON(net_number) <= INET_ATON(?)
AND INET_ATON(net_broadcast) >= INET_ATON(?)
ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
";
} else {
$query = "
SELECT id FROM ip6nets
WHERE INET6_ATON(net_number) <= INET6_ATON(?)
AND INET6_ATON(net_broadcast) >= INET6_ATON(?)
ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1
";
}
$nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]);
if (sizeof($nets) > 0) {
return $nets[0]->country;
}
return 'CH';
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Shortcut to creating a progress bar of a particular format with a particular message.
*
* @param \Illuminate\Console\OutputStyle $output Console output object
* @param int $count Number of progress steps
* @param string $message The description
*
* @return \Symfony\Component\Console\Helper\ProgressBar
*/
public static function createProgressBar($output, $count, $message = null)
{
$bar = $output->createProgressBar($count);
$bar->setFormat(
'%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
);
if ($message) {
$bar->setMessage($message . " ...");
}
$bar->start();
return $bar;
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
/**
* Find an object that is the recipient for the specified address.
*
* @param string $address
*
* @return array
*/
public static function findObjectsByRecipientAddress($address)
{
$address = \App\Utils::normalizeAddress($address);
list($local, $domainName) = explode('@', $address);
$domain = \App\Domain::where('namespace', $domainName)->first();
if (!$domain) {
return [];
}
$user = \App\User::where('email', $address)->first();
if ($user) {
return [$user];
}
$userAliases = \App\UserAlias::where('alias', $address)->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
$userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
return [];
}
/**
* Retrieve the network ID and Type from a client address
*
* @param string $clientAddress The IPv4 or IPv6 address.
*
* @return array An array of ID and class or null and null.
*/
public static function getNetFromAddress($clientAddress)
{
if (strpos($clientAddress, ':') === false) {
$net = \App\IP4Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP4Net::class];
}
} else {
$net = \App\IP6Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP6Net::class];
}
}
return [null, null];
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Normalize an email address.
*
* This means to lowercase and strip components separated with recipient delimiters.
*
* @param string $address The address to normalize.
*
* @return string
*/
public static function normalizeAddress($address)
{
$address = strtolower($address);
list($local, $domain) = explode('@', $address);
if (strpos($local, '+') === false) {
return "{$local}@{$domain}";
}
$localComponents = explode('+', $local);
$local = array_pop($localComponents);
return "{$local}@{$domain}";
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return integer
*/
public static function uuidInt(): int
{
$hex = Uuid::uuid4();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return Uuid::uuid4()->toString();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
- * @param string $route Route/Path
+ * @param string $route Route/Path
+ * @param int|null $tenantId Current tenant
+ *
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
- public static function serviceUrl(string $route): string
+ public static function serviceUrl(string $route, $tenantId = null): string
{
- $url = \config('app.public_url');
+ $url = \App\Tenant::getConfig($tenantId, 'app.public_url');
if (!$url) {
- $url = \config('app.url');
+ $url = \App\Tenant::getConfig($tenantId, 'app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
return floatval($rates[$targetCurrency]);
}
}
diff --git a/src/database/migrations/2021_07_12_100000_create_tenant_settings_table.php b/src/database/migrations/2021_07_12_100000_create_tenant_settings_table.php
new file mode 100644
index 00000000..46a6d3bc
--- /dev/null
+++ b/src/database/migrations/2021_07_12_100000_create_tenant_settings_table.php
@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateTenantSettingsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'tenant_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('tenant_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['tenant_id', 'key']);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('tenant_settings');
+ }
+}
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 51509bac..3f9a4967 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,80 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxgroups' => ':x distribution lists have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
+ 'siteuser' => ':site User',
+
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php
index b3b31416..bd42b25c 100644
--- a/src/tests/Browser/Meet/RoomControlsTest.php
+++ b/src/tests/Browser/Meet/RoomControlsTest.php
@@ -1,377 +1,377 @@
<?php
namespace Tests\Browser\Meet;
use App\OpenVidu\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomControlsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->setupTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test fullscreen buttons
*
* @group openvidu
*/
public function testFullscreen(): void
{
// TODO: This test does not work in headless mode
$this->markTestIncomplete();
/*
$this->browse(function (Browser $browser) {
// Join the room as an owner (authenticate)
$browser->visit(new RoomPage('john'))
->click('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->click('@setup-button')
->waitFor('@session')
// Test fullscreen for the whole room
->click('@menu button.link-fullscreen.closed')
->assertVisible('@toolbar')
->assertVisible('@session')
->assertMissing('nav')
->assertMissing('@menu button.link-fullscreen.closed')
->click('@menu button.link-fullscreen.open')
->assertVisible('nav')
// Test fullscreen for the participant video
->click('@session button.link-fullscreen.closed')
->assertVisible('@session')
->assertMissing('@toolbar')
->assertMissing('nav')
->assertMissing('@session button.link-fullscreen.closed')
->click('@session button.link-fullscreen.open')
->assertVisible('nav')
->assertVisible('@toolbar');
});
*/
}
/**
* Test nickname and muting audio/video
*
* @group openvidu
*/
public function testNicknameAndMuting(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->keys('@setup-nickname-input', '{enter}') // Test form submit with Enter key
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert current UI state
$owner->assertToolbar([
'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'options' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertAudioMuted('video', true)
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Assert current UI state
$guest->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test nickname change propagation
$guest->setNickname('div.meet-video.self', 'guest');
$owner->waitFor('div.meet-video:not(.self) .meet-nickname')
->assertSeeIn('div.meet-video:not(.self) .meet-nickname', 'guest');
// Test muting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
- ->assertVisible('div.meet-video.self .status .status-audio');
+ ->waitFor('div.meet-video.self .status .status-audio');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using OpenVidu API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-audio');
// Test unmuting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio');
// Test muting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
$owner->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-audio');
$guest->waitFor('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', true);
// Test unmuting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
$owner->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', false);
// Test muting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-video');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using OpenVidu API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-video');
// Test unmuting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-video');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-video');
// Test muting other user
$guest->with('div.meet-video:not(.self)', function (Browser $browser) {
$browser->click('.controls button.link-audio')
->assertAudioMuted('video', true)
->assertVisible('.controls button.link-audio.text-danger')
->click('.controls button.link-audio')
->assertAudioMuted('video', false)
->assertVisible('.controls button.link-audio:not(.text-danger)');
});
});
}
/**
* Test text chat
*
* @group openvidu
*/
public function testChat(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
// ->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test chat elements
$owner->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('@chat')
->assertVisible('@session')
->assertFocused('@chat-input')
->assertElementsCount('@chat-list .message', 0)
->keys('@chat-input', 'test1', '{enter}')
->assertValue('@chat-input', '')
->waitFor('@chat-list .message')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
$guest->waitFor('@menu button.link-chat .badge')
->assertTextRegExp('@menu button.link-chat .badge', '/^1$/')
->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('@menu button.link-chat .badge')
->assertVisible('@chat')
->assertVisible('@session')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
// Test the number of (hidden) incoming messages
$guest->click('@menu button.link-chat')
->assertMissing('@chat');
$owner->keys('@chat-input', 'test2', '{enter}', 'test3', '{enter}')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertElementsCount('@chat-list .message div', 4)
->assertSeeIn('@chat-list .message div:last-child', 'test3');
$guest->waitFor('@menu button.link-chat .badge')
->assertSeeIn('@menu button.link-chat .badge', '2')
->click('@menu button.link-chat')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test3')
->keys('@chat-input', 'guest1', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
$owner->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
// Test nickname change is propagated to chat messages
$guest->setNickname('div.meet-video.self', 'guest')
->keys('@chat-input', 'guest2', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
$owner->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
// TODO: Test text chat features, e.g. link handling
});
}
/**
* Test screen sharing
*
* @group openvidu
*/
public function testShareScreen(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test screen sharing
$owner->assertToolbarButtonState('screen', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertElementsCount('@session div.meet-video', 1)
->click('@menu button.link-screen')
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1)
->assertToolbarButtonState('screen', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
$guest
->whenAvailable('div.meet-video.screen', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1);
});
}
}
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
index 1585de10..41fabb68 100644
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -1,574 +1,574 @@
<?php
namespace Tests\Browser\Meet;
use App\OpenVidu\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomSetupTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->setupTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test non-existing room
*
* @group openvidu
*/
public function testRoomNonExistingRoom(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('unknown'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// FIXME: Maybe it would be better to just display the usual 404 Not Found error page?
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-status-message', "The room does not exist.")
->assertButtonDisabled('@setup-button');
});
}
/**
* Test the room setup page
*
* @group openvidu
*/
public function testRoomSetup(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('john'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// Note: I've found out that if I have another Chrome instance running
// that uses media, here the media devices will not be available
// TODO: Test enabling/disabling cam/mic in the setup widget
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-title', 'Set up your session')
->assertVisible('@setup-video')
->assertVisible('@setup-form .input-group:nth-child(1) svg')
->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone')
->assertVisible('@setup-mic-select')
->assertVisible('@setup-form .input-group:nth-child(2) svg')
->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera')
->assertVisible('@setup-cam-select')
->assertVisible('@setup-form .input-group:nth-child(3) svg')
->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname')
->assertValue('@setup-nickname-input', '')
->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name')
->assertMissing('@setup-password-input')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
});
}
/**
* Test two users in a room (joining/leaving and some basic functionality)
*
* @group openvidu
* @depends testRoomSetup
*/
public function testTwoUsersInARoom(): void
{
$this->browse(function (Browser $browser, Browser $guest) {
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
// In another window join the room as the owner (authenticate)
$browser->on(new RoomPage('john'))
->assertSeeIn('@setup-button', "I'm the owner")
->clickWhenEnabled('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']);
});
}
$browser->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'john')
// Join the room (click the button twice, to make sure it does not
// produce redundant participants/subscribers in the room)
->clickWhenEnabled('@setup-button')
->pause(10)
->click('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertMissing('#header-menu')
->assertSeeIn('@counter', 1);
if (!$browser->isPhone()) {
$browser->assertMissing('#footer-menu');
} else {
$browser->assertVisible('#footer-menu');
}
// After the owner "opened the room" guest should be able to join
$guest->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertSeeIn('@counter', 2);
// Check guest's elements in the owner's window
$browser
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.controls button.link-setup')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login')
->assertVisible('#header-menu');
// Expect the participant removed from other users windows
$browser->waitUntilMissing('@session div.meet-video:not(.self)')
->assertSeeIn('@counter', 1);
// Join the room as guest again
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Leave the room as the room owner
// TODO: Test leaving the room by closing the browser window,
// it should not destroy the session
$browser->click('@menu button.link-logout')
->waitForLocation('/dashboard');
// Expect other participants be informed about the end of the session
$guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Room closed')
->assertSeeIn('@body', "The session has been closed by the room owner.")
->assertMissing('@button-cancel')
->assertSeeIn('@button-action', 'Close')
->click('@button-action');
})
->assertMissing('#leave-dialog')
->waitForLocation('/login');
});
}
/**
* Test two subscribers-only users in a room
*
* @group openvidu
* @depends testTwoUsersInARoom
*/
public function testSubscribers(): void
{
$this->browse(function (Browser $browser, Browser $guest) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session .meet-subscriber', 1)
->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'options' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
]);
// After the owner "opened the room" guest should be able to join
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session div.meet-subscriber', 2)
->assertSeeIn('@counter', 2)
->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
]);
// Check guest's elements in the owner's window
$browser
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session .meet-subscriber', 2)
->assertSeeIn('@counter', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login');
// Expect the participant removed from other users windows
$browser->waitUntilMissing('@session .meet-subscriber:not(.self)');
});
}
/**
* Test demoting publisher to a subscriber
*
* @group openvidu
* @depends testSubscribers
*/
public function testDemoteToSubscriber(): void
{
$this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('@session video');
// In one browser window act as a guest
$guest1->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('div.meet-video.self')
->waitFor('div.meet-video:not(.self)')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0)
// assert there's no moderator-related features for this guess available
->click('@session .meet-video.self .meet-nickname')
->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) {
$browser->assertMissing('.permissions');
})
->click('@session .meet-video:not(.self) .meet-nickname')
->pause(50)
->assertMissing('.dropdown-menu');
// Demote the guest to a subscriber
$browser
- ->waitFor('div.meet-video.self')
- ->waitFor('div.meet-video:not(.self)')
+ ->waitFor('div.meet-video.self video')
+ ->waitFor('div.meet-video:not(.self) video')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session .meet-subscriber', 0)
->click('@session .meet-video:not(.self) .meet-nickname')
->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-video:not(.self)')
->waitFor('@session div.meet-subscriber')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
$guest1
->waitUntilMissing('@session .meet-video.self')
->waitFor('@session div.meet-subscriber')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
// Join as another user to make sure the role change is propagated to new connections
$guest2->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('div.meet-subscriber:not(.self)')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 2)
->click('@toolbar .link-logout');
// Promote the guest back to a publisher
$browser
->click('@session .meet-subscriber .meet-nickname')
->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertNotChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitFor('@session .meet-video:not(.self) video')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
$guest1
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->click('@button-action');
})
->waitFor('@session .meet-video.self')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
// Demote the owner to a subscriber
$browser
->click('@session .meet-video.self .meet-nickname')
->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-video.self')
->waitFor('@session div.meet-subscriber.self')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
// Promote the owner to a publisher
$browser
->click('@session .meet-subscriber.self .meet-nickname')
->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertNotChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-subscriber.self')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->click('@button-action');
})
->waitFor('@session div.meet-video.self')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
});
}
/**
* Test the media setup dialog
*
* @group openvidu
* @depends testDemoteToSubscriber
*/
public function testMediaSetupDialog(): void
{
$this->browse(function (Browser $browser, $guest) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form');
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form');
$browser->waitFor('@session video')
->click('.controls button.link-setup')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->assertVisible('form video')
->assertVisible('form > div:nth-child(1) video')
->assertVisible('form > div:nth-child(1) .volume')
->assertVisible('form > div:nth-child(2) svg')
->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone')
->assertVisible('form > div:nth-child(2) select')
->assertVisible('form > div:nth-child(3) svg')
->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera')
->assertVisible('form > div:nth-child(3) select')
->assertMissing('@button-cancel')
->assertSeeIn('@button-action', 'Close')
->click('@button-action');
})
->assertMissing('#media-setup-dialog')
// Test mute audio and video
->click('.controls button.link-setup')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->select('form > div:nth-child(2) select', '')
->select('form > div:nth-child(3) select', '')
->click('@button-action');
})
->assertMissing('#media-setup-dialog')
->assertVisible('@session .meet-video .status .status-audio')
->assertVisible('@session .meet-video .status .status-video');
$guest->waitFor('@session video')
->assertVisible('@session .meet-video .status .status-audio')
->assertVisible('@session .meet-video .status .status-video');
});
}
}
diff --git a/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php b/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php
new file mode 100644
index 00000000..975dbc2a
--- /dev/null
+++ b/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Feature\Console\Scalpel\TenantSetting;
+
+use Tests\TestCase;
+
+class CreateCommandTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ \App\TenantSetting::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \App\TenantSetting::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the command
+ */
+ public function testHandle(): void
+ {
+ $tenant = \App\Tenant::whereNotIn('id', [1])->first();
+
+ $this->artisan("scalpel:tenant-setting:create --key=test --value=init --tenant_id={$tenant->id}")
+ ->assertExitCode(0);
+
+ $setting = $tenant->settings()->where('key', 'test')->first();
+
+ $this->assertSame('init', $setting->fresh()->value);
+ $this->assertSame('init', $tenant->fresh()->getSetting('test'));
+ }
+}
diff --git a/src/tests/Feature/Console/Scalpel/TenantSetting/UpdateCommandTest.php b/src/tests/Feature/Console/Scalpel/TenantSetting/UpdateCommandTest.php
new file mode 100644
index 00000000..d957a575
--- /dev/null
+++ b/src/tests/Feature/Console/Scalpel/TenantSetting/UpdateCommandTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Feature\Console\Scalpel\TenantSetting;
+
+use Tests\TestCase;
+
+class UpdateCommandTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ \App\TenantSetting::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \App\TenantSetting::truncate();
+
+ parent::tearDown();
+ }
+
+ public function testHandle(): void
+ {
+ $this->artisan("scalpel:tenant-setting:update unknown --value=test")
+ ->assertExitCode(1)
+ ->expectsOutput("No such tenant-setting unknown");
+
+ $tenant = \App\Tenant::whereNotIn('id', [1])->first();
+ $tenant->setSetting('test', 'test-old');
+ $setting = $tenant->settings()->where('key', 'test')->first();
+
+ $this->assertSame('test-old', $setting->value);
+
+ $this->artisan("scalpel:tenant-setting:update {$setting->id} --value=test")
+ ->assertExitCode(0);
+
+ $this->assertSame('test', $setting->fresh()->value);
+ $this->assertSame('test', $tenant->fresh()->getSetting('test'));
+ }
+}
diff --git a/src/tests/Feature/Console/Tenant/ListSettingsTest.php b/src/tests/Feature/Console/Tenant/ListSettingsTest.php
new file mode 100644
index 00000000..852f6c91
--- /dev/null
+++ b/src/tests/Feature/Console/Tenant/ListSettingsTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Tests\Feature\Console\Tenant;
+
+use App\TenantSetting;
+use Tests\TestCase;
+
+class ListSettingsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ TenantSetting::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ TenantSetting::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ $code = \Artisan::call("tenant:list-settings unknown");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("Unable to find the tenant.", $output);
+
+ $tenant = \App\Tenant::whereNotIn('id', [1])->first();
+
+ // A tenant without settings
+ $code = \Artisan::call("tenant:list-settings {$tenant->id}");
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertSame("", $output);
+
+ $tenant->setSetting('app.test1', 'test1');
+ $tenant->setSetting('app.test2', 'test2');
+
+ // A tenant with some settings
+ $expected = "app.test1: test1\napp.test2: test2";
+
+ $code = \Artisan::call("tenant:list-settings {$tenant->id}");
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertSame($expected, $output);
+ }
+}
diff --git a/src/tests/Feature/Jobs/PasswordResetEmailTest.php b/src/tests/Feature/Jobs/PasswordResetEmailTest.php
index 355fee06..7f623775 100644
--- a/src/tests/Feature/Jobs/PasswordResetEmailTest.php
+++ b/src/tests/Feature/Jobs/PasswordResetEmailTest.php
@@ -1,71 +1,77 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\PasswordResetEmail;
use App\Mail\PasswordReset;
use App\User;
use App\VerificationCode;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class PasswordResetEmailTest extends TestCase
{
private $code;
/**
* {@inheritDoc}
*
* @return void
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('PasswordReset@UserAccount.com');
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$this->deleteTestUser('PasswordReset@UserAccount.com');
parent::tearDown();
}
/**
* Test job handle
*
* @return void
*/
public function testPasswordResetEmailHandle()
{
$code = new VerificationCode([
'mode' => 'password-reset',
]);
$user = $this->getTestUser('PasswordReset@UserAccount.com');
$user->verificationcodes()->save($code);
$user->setSettings(['external_email' => 'etx@email.com']);
Mail::fake();
// Assert that no jobs were pushed...
Mail::assertNothingSent();
$job = new PasswordResetEmail($code);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PasswordReset::class, 1);
// Assert the mail was sent to the code's email
Mail::assertSent(PasswordReset::class, function ($mail) use ($code) {
return $mail->hasTo($code->user->getSetting('external_email'));
});
+
+ // Assert sender
+ Mail::assertSent(PasswordReset::class, function ($mail) {
+ return $mail->hasFrom(\config('mail.from.address'), \config('mail.from.name'))
+ && $mail->hasReplyTo(\config('mail.reply_to.address'), \config('mail.reply_to.name'));
+ });
}
}
diff --git a/src/tests/Feature/TenantTest.php b/src/tests/Feature/TenantTest.php
index b7b274b5..4f9681b4 100644
--- a/src/tests/Feature/TenantTest.php
+++ b/src/tests/Feature/TenantTest.php
@@ -1,40 +1,65 @@
<?php
namespace Tests\Feature;
use App\Tenant;
+use App\TenantSetting;
use Tests\TestCase;
class TenantTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
+
+ TenantSetting::truncate();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
+ TenantSetting::truncate();
+
parent::tearDown();
}
+ /**
+ * Test Tenant::getConfig() method
+ */
+ public function testGetConfig(): void
+ {
+ // No tenant id specified
+ $this->assertSame(\config('app.name'), Tenant::getConfig(null, 'app.name'));
+ $this->assertSame(\config('app.env'), Tenant::getConfig(null, 'app.env'));
+ $this->assertSame(null, Tenant::getConfig(null, 'app.unknown'));
+
+ $tenant = Tenant::whereNotIn('id', [1])->first();
+ $tenant->setSetting('app.test', 'test');
+
+ // Tenant specified
+ $this->assertSame($tenant->title, Tenant::getConfig($tenant->id, 'app.name'));
+ $this->assertSame('test', Tenant::getConfig($tenant->id, 'app.test'));
+ $this->assertSame(\config('app.env'), Tenant::getConfig($tenant->id, 'app.env'));
+ $this->assertSame(null, Tenant::getConfig($tenant->id, 'app.unknown'));
+ }
+
/**
* Test Tenant::wallet() method
*/
public function testWallet(): void
{
$tenant = Tenant::find(1);
$user = \App\User::where('email', 'reseller@' . \config('app.domain'))->first();
$wallet = $tenant->wallet();
$this->assertInstanceof(\App\Wallet::class, $wallet);
$this->assertSame($user->wallets->first()->id, $wallet->id);
}
}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index 6fdf9efb..9f3e0d0e 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,899 +1,909 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
public function tearDown(): void
{
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignPlan()
*/
public function testAssignPlan(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignSku()
*/
public function testAssignSku(): void
{
$this->markTestIncomplete();
}
/**
* Verify a wallet assigned a controller is among the accounts of the assignee.
*/
public function testAccounts(): void
{
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$this->assertTrue($userA->wallets()->count() == 1);
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
public function testCanDelete(): void
{
$this->markTestIncomplete();
}
/**
* Test User::canRead() method
*/
public function testCanRead(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canRead($admin));
$this->assertTrue($admin->canRead($john));
$this->assertTrue($admin->canRead($jack));
$this->assertTrue($admin->canRead($reseller1));
$this->assertTrue($admin->canRead($reseller2));
$this->assertTrue($admin->canRead($domain));
$this->assertTrue($admin->canRead($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canRead($john));
$this->assertTrue($reseller1->canRead($jack));
$this->assertTrue($reseller1->canRead($reseller1));
$this->assertTrue($reseller1->canRead($domain));
$this->assertTrue($reseller1->canRead($domain->wallet()));
$this->assertFalse($reseller1->canRead($reseller2));
$this->assertFalse($reseller1->canRead($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canRead($reseller2));
$this->assertFalse($reseller2->canRead($john));
$this->assertFalse($reseller2->canRead($jack));
$this->assertFalse($reseller2->canRead($reseller1));
$this->assertFalse($reseller2->canRead($domain));
$this->assertFalse($reseller2->canRead($domain->wallet()));
$this->assertFalse($reseller2->canRead($admin));
// Normal user - account owner
$this->assertTrue($john->canRead($john));
$this->assertTrue($john->canRead($ned));
$this->assertTrue($john->canRead($jack));
$this->assertTrue($john->canRead($domain));
$this->assertTrue($john->canRead($domain->wallet()));
$this->assertFalse($john->canRead($reseller1));
$this->assertFalse($john->canRead($reseller2));
$this->assertFalse($john->canRead($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canRead($jack));
$this->assertFalse($jack->canRead($john));
$this->assertFalse($jack->canRead($domain));
$this->assertFalse($jack->canRead($domain->wallet()));
$this->assertFalse($jack->canRead($reseller1));
$this->assertFalse($jack->canRead($reseller2));
$this->assertFalse($jack->canRead($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canRead($ned));
$this->assertTrue($ned->canRead($john));
$this->assertTrue($ned->canRead($jack));
$this->assertTrue($ned->canRead($domain));
$this->assertTrue($ned->canRead($domain->wallet()));
$this->assertFalse($ned->canRead($reseller1));
$this->assertFalse($ned->canRead($reseller2));
$this->assertFalse($ned->canRead($admin));
}
/**
* Test User::canUpdate() method
*/
public function testCanUpdate(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canUpdate($admin));
$this->assertTrue($admin->canUpdate($john));
$this->assertTrue($admin->canUpdate($jack));
$this->assertTrue($admin->canUpdate($reseller1));
$this->assertTrue($admin->canUpdate($reseller2));
$this->assertTrue($admin->canUpdate($domain));
$this->assertTrue($admin->canUpdate($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canUpdate($john));
$this->assertTrue($reseller1->canUpdate($jack));
$this->assertTrue($reseller1->canUpdate($reseller1));
$this->assertTrue($reseller1->canUpdate($domain));
$this->assertTrue($reseller1->canUpdate($domain->wallet()));
$this->assertFalse($reseller1->canUpdate($reseller2));
$this->assertFalse($reseller1->canUpdate($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canUpdate($reseller2));
$this->assertFalse($reseller2->canUpdate($john));
$this->assertFalse($reseller2->canUpdate($jack));
$this->assertFalse($reseller2->canUpdate($reseller1));
$this->assertFalse($reseller2->canUpdate($domain));
$this->assertFalse($reseller2->canUpdate($domain->wallet()));
$this->assertFalse($reseller2->canUpdate($admin));
// Normal user - account owner
$this->assertTrue($john->canUpdate($john));
$this->assertTrue($john->canUpdate($ned));
$this->assertTrue($john->canUpdate($jack));
$this->assertTrue($john->canUpdate($domain));
$this->assertFalse($john->canUpdate($domain->wallet()));
$this->assertFalse($john->canUpdate($reseller1));
$this->assertFalse($john->canUpdate($reseller2));
$this->assertFalse($john->canUpdate($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canUpdate($jack));
$this->assertFalse($jack->canUpdate($john));
$this->assertFalse($jack->canUpdate($domain));
$this->assertFalse($jack->canUpdate($domain->wallet()));
$this->assertFalse($jack->canUpdate($reseller1));
$this->assertFalse($jack->canUpdate($reseller2));
$this->assertFalse($jack->canUpdate($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canUpdate($ned));
$this->assertTrue($ned->canUpdate($john));
$this->assertTrue($ned->canUpdate($jack));
$this->assertTrue($ned->canUpdate($domain));
$this->assertFalse($ned->canUpdate($domain->wallet()));
$this->assertFalse($ned->canUpdate($reseller1));
$this->assertFalse($ned->canUpdate($reseller2));
$this->assertFalse($ned->canUpdate($admin));
}
/**
* Test user create/creating observer
*/
public function testCreate(): void
{
Queue::fake();
$domain = \config('app.domain');
$user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]);
$result = User::where('email', 'user-test@' . $domain)->first();
$this->assertSame('user-test@' . $domain, $result->email);
$this->assertSame($user->id, $result->id);
$this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status);
}
/**
* Verify user creation process
*/
public function testCreateJobs(): void
{
// Fake the queue, assert that no jobs were pushed...
Queue::fake();
Queue::assertNothingPushed();
$user = User::create([
'email' => 'user-test@' . \config('app.domain')
]);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
/*
FIXME: Looks like we can't really do detailed assertions on chained jobs
Another thing to consider is if we maybe should run these jobs
independently (not chained) and make sure there's no race-condition
in status update
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
});
*/
}
/**
* Tests for User::domains()
*/
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('useraccount.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
// Public domains of other tenants should not be returned
$tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertNotContains($domain->namespace, $domains);
}
/**
* Test User::hasSku() method
*/
public function testHasSku(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->assertTrue($john->hasSku('mailbox'));
$this->assertTrue($john->hasSku('storage'));
$this->assertFalse($john->hasSku('beta'));
$this->assertFalse($john->hasSku('unknown'));
}
public function testUserQuota(): void
{
// TODO: This test does not test much, probably could be removed
// or moved to somewhere else, or extended with
// other entitlements() related cases.
$user = $this->getTestUser('john@kolab.org');
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$count = 0;
foreach ($user->entitlements()->get() as $entitlement) {
if ($entitlement->sku_id == $storage_sku->id) {
$count += 1;
}
}
$this->assertTrue($count == 5);
}
/**
* Test user deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$id = $user->id;
$this->assertCount(7, $user->entitlements()->get());
$user->delete();
$this->assertCount(0, $user->entitlements()->get());
$this->assertTrue($user->fresh()->trashed());
$this->assertFalse($user->fresh()->isDeleted());
// Delete the user for real
$job = new \App\Jobs\User\DeleteJob($id);
$job->handle();
$this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted());
$user->forceDelete();
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
// Test an account with users, domain, and group
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->count());
$this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame(\config('app.name') . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test user restoring
*/
public function testRestore(): void
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
}
/**
* Tests for UserAliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
- * Tests for UserSettingsTrait::setSettings() and getSetting()
+ * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
+
+ // Test getSettings() method
+ $this->assertSame(
+ [
+ 'first_name' => 'Firstname2',
+ 'last_name' => 'Lastname2',
+ 'unknown' => null,
+ ],
+ $user->getSettings(['first_name', 'last_name', 'unknown'])
+ );
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$this->assertSame($wallet->id, $users[0]->wallet_id);
$this->assertSame($wallet->id, $users[1]->wallet_id);
$this->assertSame($wallet->id, $users[2]->wallet_id);
$this->assertSame($wallet->id, $users[3]->wallet_id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
public function testWallets(): void
{
$this->markTestIncomplete();
}
}
diff --git a/src/tests/MailInterceptTrait.php b/src/tests/MailInterceptTrait.php
index 544d88ad..8782e188 100644
--- a/src/tests/MailInterceptTrait.php
+++ b/src/tests/MailInterceptTrait.php
@@ -1,79 +1,79 @@
<?php
namespace Tests;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Mail;
use KirschbaumDevelopment\MailIntercept\WithMailInterceptor;
trait MailInterceptTrait
{
use WithMailInterceptor;
/**
* Extract content of a email message.
*
* @param \Illuminate\Mail\Mailable $mail Mailable object
*
* @return array Parsed message data:
* - 'plain': Plain text body
* - 'html: HTML body
* - 'message': Swift_Message object
*/
protected function fakeMail(Mailable $mail): array
{
$this->interceptMail();
Mail::send($mail);
- $message = $this->interceptedMail()->first();
+ $message = $this->interceptedMail()->last();
// SwiftMailer does not have methods to get the bodies, we'll parse the message
list($plain, $html) = $this->extractMailBody($message->toString());
return [
'plain' => $plain,
'html' => $html,
'message' => $message,
];
}
/**
* Simple message parser to extract plain and html body
*
* @param string $message Email message as string
*
* @return array Plain text and HTML body
*/
protected function extractMailBody(string $message): array
{
// Note that we're not supporting every message format, we only
// support what Laravel/SwiftMailer produces
// TODO: It may stop working if we start using attachments
$plain = '';
$html = '';
if (preg_match('/[\s\t]boundary="([^"]+)"/', $message, $matches)) {
// multipart message assume plain and html parts
$split = preg_split('/--' . preg_quote($matches[1]) . '/', $message);
list($plain_head, $plain) = explode("\r\n\r\n", $split[1], 2);
list($html_head, $html) = explode("\r\n\r\n", $split[2], 2);
if (strpos($plain_head, 'Content-Transfer-Encoding: quoted-printable') !== false) {
$plain = quoted_printable_decode($plain);
}
if (strpos($html_head, 'Content-Transfer-Encoding: quoted-printable') !== false) {
$html = quoted_printable_decode($html);
}
} else {
list($header, $html) = explode("\r\n\r\n", $message, 2);
if (strpos($header, 'Content-Transfer-Encoding: quoted-printable') !== false) {
$html = quoted_printable_decode($html);
}
}
return [$plain, $html];
}
}
diff --git a/src/tests/Unit/Mail/HelperTest.php b/src/tests/Unit/Mail/HelperTest.php
index a8fdb980..62735b25 100644
--- a/src/tests/Unit/Mail/HelperTest.php
+++ b/src/tests/Unit/Mail/HelperTest.php
@@ -1,84 +1,152 @@
<?php
namespace Tests\Unit\Mail;
use App\Mail\Helper;
+use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class HelperTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
+
$this->deleteTestUser('mail-helper-test@kolabnow.com');
+ \App\TenantSetting::truncate();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('mail-helper-test@kolabnow.com');
+ \App\TenantSetting::truncate();
+
parent::tearDown();
}
+ /**
+ * Test Helper::sendMail()
+ */
+ public function testSendMail(): void
+ {
+ Mail::fake();
+
+ $tenant = \App\Tenant::whereNotIn('id', [1])->first();
+ $invitation = new \App\SignupInvitation();
+ $invitation->id = 'test';
+ $mail = new \App\Mail\SignupInvitation($invitation);
+
+ Helper::sendMail($mail, null, ['to' => 'to@test.com', 'cc' => 'cc@test.com']);
+
+ Mail::assertSent(\App\Mail\SignupInvitation::class, 1);
+ Mail::assertSent(\App\Mail\SignupInvitation::class, function ($mail) {
+ return $mail->hasTo('to@test.com')
+ && $mail->hasCc('cc@test.com')
+ && $mail->hasFrom(\config('mail.from.address'), \config('mail.from.name'))
+ && $mail->hasReplyTo(\config('mail.reply_to.address'), \config('mail.reply_to.name'));
+ });
+
+ // Test with a tenant (but no per-tenant settings)
+ Mail::fake();
+
+ $invitation->tenant_id = $tenant->id;
+ $mail = new \App\Mail\SignupInvitation($invitation);
+
+ Helper::sendMail($mail, $tenant->id, ['to' => 'to@test.com', 'cc' => 'cc@test.com']);
+
+ Mail::assertSent(\App\Mail\SignupInvitation::class, 1);
+ Mail::assertSent(\App\Mail\SignupInvitation::class, function ($mail) {
+ return $mail->hasTo('to@test.com')
+ && $mail->hasCc('cc@test.com')
+ && $mail->hasFrom(\config('mail.from.address'), \config('mail.from.name'))
+ && $mail->hasReplyTo(\config('mail.reply_to.address'), \config('mail.reply_to.name'));
+ });
+
+ // Test with a tenant (but with per-tenant settings)
+ Mail::fake();
+
+ $tenant->setSettings([
+ 'mail.from.address' => 'from@test.com',
+ 'mail.from.name' => 'from name',
+ 'mail.reply_to.address' => 'replyto@test.com',
+ 'mail.reply_to.name' => 'replyto name',
+ ]);
+
+ $mail = new \App\Mail\SignupInvitation($invitation);
+
+ Helper::sendMail($mail, $tenant->id, ['to' => 'to@test.com']);
+
+ Mail::assertSent(\App\Mail\SignupInvitation::class, 1);
+ Mail::assertSent(\App\Mail\SignupInvitation::class, function ($mail) {
+ return $mail->hasTo('to@test.com')
+ && $mail->hasFrom('from@test.com', 'from name')
+ && $mail->hasReplyTo('replyto@test.com', 'replyto name');
+ });
+
+ // TODO: Test somehow log entries, maybe with timacdonald/log-fake package
+ // TODO: Test somehow exception case
+ }
+
/**
* Test Helper::userEmails()
*/
public function testUserEmails(): void
{
$user = $this->getTestUser('mail-helper-test@kolabnow.com');
// User with no mailbox and no external email
list($to, $cc) = Helper::userEmails($user);
$this->assertSame(null, $to);
$this->assertSame([], $cc);
list($to, $cc) = Helper::userEmails($user, true);
$this->assertSame(null, $to);
$this->assertSame([], $cc);
// User with no mailbox but with external email
$user->setSetting('external_email', 'external@test.com');
list($to, $cc) = Helper::userEmails($user);
$this->assertSame('external@test.com', $to);
$this->assertSame([], $cc);
list($to, $cc) = Helper::userEmails($user, true);
$this->assertSame('external@test.com', $to);
$this->assertSame([], $cc);
// User with mailbox and external email
- $sku = \App\Sku::where('title', 'mailbox')->first();
+ $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$user->assignSku($sku);
list($to, $cc) = Helper::userEmails($user);
$this->assertSame($user->email, $to);
$this->assertSame([], $cc);
list($to, $cc) = Helper::userEmails($user, true);
$this->assertSame($user->email, $to);
$this->assertSame(['external@test.com'], $cc);
// User with mailbox, but no external email
$user->setSetting('external_email', null);
list($to, $cc) = Helper::userEmails($user);
$this->assertSame($user->email, $to);
$this->assertSame([], $cc);
list($to, $cc) = Helper::userEmails($user, true);
$this->assertSame($user->email, $to);
$this->assertSame([], $cc);
}
}
diff --git a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php
index f3963900..7f601a20 100644
--- a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php
+++ b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php
@@ -1,62 +1,125 @@
<?php
namespace Tests\Unit\Mail;
use App\Jobs\WalletCheck;
use App\Mail\NegativeBalanceBeforeDelete;
use App\User;
use App\Wallet;
use Tests\MailInterceptTrait;
use Tests\TestCase;
class NegativeBalanceBeforeDeleteTest extends TestCase
{
use MailInterceptTrait;
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ \App\TenantSetting::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ \App\TenantSetting::truncate();
+
+ parent::tearDown();
+ }
+
/**
* Test email content
*/
public function testBuild(): void
{
$user = $this->getTestUser('ned@kolab.org');
$wallet = $user->wallets->first();
$wallet->balance = -100;
$wallet->save();
$threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE);
\config([
'app.support_url' => 'https://kolab.org/support',
]);
$mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user));
$html = $mail['html'];
$plain = $mail['plain'];
$walletUrl = \App\Utils::serviceUrl('/wallet');
$walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
$supportUrl = \config('app.support_url');
$supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
$appName = \config('app.name');
$this->assertMailSubject("$appName Final Warning", $mail['message']);
$this->assertStringStartsWith('<!DOCTYPE html>', $html);
$this->assertTrue(strpos($html, $user->name(true)) > 0);
$this->assertTrue(strpos($html, $walletLink) > 0);
$this->assertTrue(strpos($html, $supportLink) > 0);
$this->assertTrue(strpos($html, "This is a final reminder to settle your $appName") > 0);
$this->assertTrue(strpos($html, $threshold->toDateString()) > 0);
$this->assertTrue(strpos($html, "$appName Support") > 0);
$this->assertTrue(strpos($html, "$appName Team") > 0);
$this->assertStringStartsWith('Dear ' . $user->name(true), $plain);
$this->assertTrue(strpos($plain, $walletUrl) > 0);
$this->assertTrue(strpos($plain, $supportUrl) > 0);
$this->assertTrue(strpos($plain, "This is a final reminder to settle your $appName") > 0);
$this->assertTrue(strpos($plain, $threshold->toDateString()) > 0);
$this->assertTrue(strpos($plain, "$appName Support") > 0);
$this->assertTrue(strpos($plain, "$appName Team") > 0);
+
+ // Test with user that is not the same tenant as in .env
+ $user = $this->getTestUser('user@sample-tenant.dev-local');
+ $tenant = $user->tenant;
+ $wallet = $user->wallets->first();
+ $wallet->balance = -100;
+ $wallet->save();
+
+ $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE);
+
+ $tenant->setSettings([
+ 'app.support_url' => 'https://test.org/support',
+ 'app.public_url' => 'https://test.org',
+ ]);
+
+ $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $walletUrl = 'https://test.org/wallet';
+ $walletLink = sprintf('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = 'https://test.org/support';
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+
+ $this->assertMailSubject("{$tenant->title} Final Warning", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ $this->assertTrue(strpos($html, $user->name(true)) > 0);
+ $this->assertTrue(strpos($html, $walletLink) > 0);
+ $this->assertTrue(strpos($html, $supportLink) > 0);
+ $this->assertTrue(strpos($html, "This is a final reminder to settle your {$tenant->title}") > 0);
+ $this->assertTrue(strpos($html, $threshold->toDateString()) > 0);
+ $this->assertTrue(strpos($html, "{$tenant->title} Support") > 0);
+ $this->assertTrue(strpos($html, "{$tenant->title} Team") > 0);
+
+ $this->assertStringStartsWith('Dear ' . $user->name(true), $plain);
+ $this->assertTrue(strpos($plain, $walletUrl) > 0);
+ $this->assertTrue(strpos($plain, $supportUrl) > 0);
+ $this->assertTrue(strpos($plain, "This is a final reminder to settle your {$tenant->title}") > 0);
+ $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0);
+ $this->assertTrue(strpos($plain, "{$tenant->title} Support") > 0);
+ $this->assertTrue(strpos($plain, "{$tenant->title} Team") > 0);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Jan 30, 8:58 PM (37 m, 47 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426042
Default Alt Text
(341 KB)

Event Timeline