diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 522a7bc4..0aae7bc3 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1386 +1,1412 @@ close(); self::$ldap = null; } } + /** + * Validates that ldap is available as configured. + * + * @throws \Exception + */ + public static function validate(): void + { + if (empty(self::$ldap)) { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $mgmtRootDN = \config('ldap.admin.root_dn'); + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $result = $ldap->search($mgmtRootDN, '', 'base'); + if (!$result || $result->count() != 1) { + self::throwException($ldap, "Failed to find the configured management domain $mgmtRootDN"); + } + + $result = $ldap->search($hostedRootDN, '', 'base'); + if (!$result || $result->count() != 1) { + self::throwException($ldap, "Failed to find the configured hosted domain $hostedRootDN"); + } + } + } + /** * 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); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.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)) { self::addEntry( $ldap, $dn, $entry, "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)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "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); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} 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); self::addEntry( $ldap, $dn, $entry, "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); $domainBaseDN = self::baseDN($ldap, $domain->namespace); 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 resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} 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 resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * 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); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} 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) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; 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 == $groupDomain && !$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 if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $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'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases()->pluck('alias')->all(); $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 ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { 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 null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * 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 ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $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); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * 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)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Backends/OpenExchangeRates.php b/src/app/Backends/OpenExchangeRates.php index e1c9367b..ec666146 100644 --- a/src/app/Backends/OpenExchangeRates.php +++ b/src/app/Backends/OpenExchangeRates.php @@ -1,52 +1,73 @@ $apiKey, 'base' => 'USD']); $url = 'https://openexchangerates.org/api/latest.json?' . $query; $html = file_get_contents($url, false); $rates = []; if ($html && ($result = json_decode($html, true)) && !empty($result['rates'])) { foreach ($result['rates'] as $code => $rate) { $rates[strtoupper($code)] = $rate; } if ($baseCurrency != 'USD') { if ($base = $rates[$baseCurrency]) { foreach ($rates as $code => $rate) { $rates[$code] = $rate / $base; } } else { $rates = []; } } foreach ($rates as $code => $rate) { \Log::debug(sprintf("Update %s: %0.8f", $code, $rate)); } } else { throw new \Exception("Failed to parse exchange rates"); } if (count($rates) > 1) { $rates[$baseCurrency] = 1; return $rates; } throw new \Exception("Failed to retrieve exchange rates"); } + + /** + * Validates that openexchange is available as configured. + * + * @throws \Exception + */ + public static function validate(): void + { + $apiKey = \config('services.openexchangerates.api_key'); + if (!empty($apiKey)) { + $query = http_build_query(['app_id' => $apiKey]); + $url = 'https://openexchangerates.org/api/usage.json' . $query; + $html = file_get_contents($url, false); + + if ($html && ($result = json_decode($html, true)) && !empty($result['status'])) { + print($result); + } + + throw new \Exception("Failed to retrieve exchange rates status"); + } + } } diff --git a/src/app/Console/Commands/Status/Health.php b/src/app/Console/Commands/Status/Health.php new file mode 100644 index 00000000..2f0d82e8 --- /dev/null +++ b/src/app/Console/Commands/Status/Health.php @@ -0,0 +1,192 @@ +line($exception); + return false; + } + } + + private function checkOpenExchangeRates() + { + try { + OpenExchangeRates::validate(); + return true; + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + private function checkMollie() + { + try { + return Mollie::validate(); + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + private function checkLDAP() + { + try { + LDAP::validate(); + //TODO validate domain part of email? + return true; + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + private function checkIMAP($email) + { + try { + IMAP::verifyAccount($email); + return true; + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + private function checkRoundcube() + { + try { + //TODO maybe run a select? + Roundcube::dbh(); + return true; + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + private function checkMeet() + { + try { + $urls = \config('meet.api_urls'); + foreach ($urls as $url) { + $this->line("Checking $url"); + + $client = new \GuzzleHttp\Client( + [ + 'http_errors' => false, // No exceptions from Guzzle + 'base_uri' => $url, + 'verify' => \config('meet.api_verify_tls'), + 'headers' => [ + 'X-Auth-Token' => \config('meet.api_token'), + ], + 'connect_timeout' => 10, + 'timeout' => 10, + 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { + $threshold = \config('logging.slow_log'); + if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { + $url = $stats->getEffectiveUri(); + $method = $stats->getRequest()->getMethod(); + \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); + } + }, + ] + ); + + $response = $client->request('GET', "ping"); + if ($response->getStatusCode() != 200) { + $this->line("Backend not avialable: " . var_export($response, true)); + return false; + } + } + return true; + } catch (\Exception $exception) { + $this->line($exception); + return false; + } + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $email = $this->argument('user'); + + $this->line("Checking DB..."); + if ($this->checkDB()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking LDAP..."); + if ($this->checkLDAP()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking IMAP..."); + if ($this->checkIMAP($email)) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking Roundcube..."); + if ($this->checkRoundcube()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking Meet..."); + if ($this->checkMeet()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking OpenExchangeRates..."); + if ($this->checkOpenExchangeRates()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + $this->line("Checking Mollie..."); + if ($this->checkMollie()) { + $this->info("OK"); + } else { + $this->error("Not found"); + } + } +} diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index 2d15be72..c69513b7 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,628 +1,640 @@ 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 ); } + /** + * Validates that mollie available. + * + * @throws \Mollie\Api\Exceptions\ApiException on failure + * @return boolean true on success + */ + public static function validate() + { + mollie()->methods()->allActive(); + return true; + } + /** * 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()) { \Log::debug("Recurring payment for {$wallet->id}: no valid Mollie mandate"); 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 ]; \Log::debug("Recurring payment for {$wallet->id}: " . json_encode($request)); // 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; } try { // 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); $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); } } catch (\Mollie\Api\Exceptions\ApiException $e) { \Log::warning(sprintf('Mollie api call failed (%s)', $e->getMessage())); } 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) { $settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']); // Get the manadate reference we already have if ($settings['mollie_id'] && $settings['mollie_mandate_id']) { try { 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). * @param string $currency Currency code * * @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(string $type, string $currency): array { // Prefer methods in the system currency $providerMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => $currency ] ] ); // Get EUR methods (e.g. bank transfers are in EUR only) if ($currency != 'EUR') { $eurMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => 'EUR' ] ] ); // Later provider methods will override earlier ones $providerMethods = array_merge($eurMethods, $providerMethods); } $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($currency, $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() ]; } }