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 @@
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 @@
+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 @@
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
7252 Westminster Lane
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", '
', 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", '
', 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('%s', $contact, $contact);
}
if ($logo && strpos($logo, '/') === false) {
$logo = "/themes/$theme/images/$logo";
}
return [
'logo' => $logo ? "" : '',
'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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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('%s', $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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
sql,
implode(', ', $query->bindings),
$query->time / 1000
)
);
});
}
// Register some template helpers
Blade::directive(
'theme_asset',
function ($path) {
$path = trim($path, '/\'"');
return "";
}
);
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 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'%s',
$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 @@
['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 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 @@
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 @@
+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 @@
'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 @@
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 " 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 @@
= 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 @@
+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 @@
'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 @@
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