{strtolower($type)} = $endPoint; return $this; } /** * @param Persona|Propietario $persona * @return array * @throws InvalidResult */ public function sendPersona(Model\Persona|Model\Venta\Propietario $persona): array { $rut = implode('', [$persona->rut, strtoupper($persona->dv)]); try { return $this->customer->getById($rut); } catch (InvalidResult $exception) { $datos = $persona->datos; $customerData = [ 'rut' => $rut, 'nombreCompleto' => $persona->nombreCompleto(), 'email' => $datos?->email ?? '', 'telefono' => $datos?->telefono ?? '' ]; try { if (!$this->customer->add($customerData)) { throw new InvalidResult("Could not save Customer for Persona {$rut}", 409, $exception); } } catch (EmptyResponse $exception) { throw new InvalidResult("Could not save Customer for Persona {$rut}", 409, $exception); } return $this->customer->getById($rut); } } /** * @param Model\Venta $venta * @return array * @throws InvalidResult */ public function sendVenta(Model\Venta $venta): array { $customer = $this->sendPersona($venta->propietario()); try { return $this->subscription->getById($venta->id); } catch (InvalidResult $exception) { $inmobiliaria = $venta->proyecto()->inmobiliaria(); $accountKey = null; try { $accountKey = $this->getAccountKey($inmobiliaria->rut); } catch (EmptyResult) {} $subscriptionData = [ 'customer' => $customer['toku_id'], 'product_id' => $venta->id, 'venta' => $venta ]; try { if (!$this->subscription->add($subscriptionData, $accountKey)) { throw new InvalidResult("Could not save Subscription for Venta {$venta->id}", 409, $exception); } } catch (EmptyResponse $exception) { throw new InvalidResult("Could not save Subscription for Venta {$venta->id}", 409, $exception); } return $this->subscription->getById($venta->id); } } /** * @param Model\Venta $venta * @param array $cuotas_ids * @return array * @throws InvalidResult */ public function sendCuotas(Model\Venta $venta, array $cuotas_ids = []): array { $customer = $this->sendPersona($venta->propietario()); $subscription = $this->sendVenta($venta); $customerInvoices = []; try { $customerInvoices = $this->invoice->getByCustomer($customer['toku_id']); $customerInvoices = array_filter($customerInvoices, function($invoiceRow) use ($subscription) { return $invoiceRow['subscription'] === $subscription['toku_id']; }); } catch (EmptyResponse) {} $inmobiliaria = $venta->proyecto()->inmobiliaria(); $accountKey = null; try { $accountKey = $this->getAccountKey($inmobiliaria->rut); } catch (EmptyResult) {} $invoices = []; $errors = []; foreach ($venta->formaPago()->pie->cuotas() as $cuota) { if (count($cuotas_ids) > 0 and !in_array($cuota->id, $cuotas_ids)) { continue; } if (count($customerInvoices) and in_array($cuota->id, array_column($customerInvoices, 'invoice_external_id'))) { $invoice = array_find($customerInvoices, function($invoiceRow) use ($cuota) { return $invoiceRow['invoice_external_id'] === $cuota->id; }); if ($invoice !== null) { $invoices []= $invoice; $this->invoice->save($invoice); continue; } } try { $invoices []= $this->invoice->getById($cuota->id); } catch (InvalidResult $exception) { try { $invoiceData = [ 'customer' => $customer['toku_id'], 'product_id' => $venta->id, 'subscription' => $subscription['toku_id'], 'cuota' => $cuota, 'venta' => $venta ]; if (!$this->invoice->add($invoiceData, $accountKey)) { throw new EmptyResponse("Could not add Invoice for Cuota {$cuota->id}", $exception); } $invoices []= $this->invoice->getById($cuota->id); } catch (EmptyResponse $exception) { $this->logger->warning($exception); $errors []= $exception; } } } if (count($errors) > 0) { $this->logger->warning("Revisar el envío de cuotas de la Venta {$venta->id}"); } return $invoices; } /** * @param array $input * @return bool * @throws InvalidResult */ public function successEvent(array $input): bool { $validEvents = ['payment_intent.succeeded', 'payment.succeeded', 'transaction.success', 'transaction.bulk_success', 'payment_intent.succeeded_batch']; if (!in_array($input['event_type'], $validEvents)) { $this->logger->warning("{$input['event_type']} is not a valid event"); throw new InvalidResult("{$input['event_type']} is not a valid event", 422); } switch ($input['event_type']) { case 'payment_intent.succeeded_batch': case 'transaction.bulk_success': return $this->successBulk($input); case 'transaction.success': return $this->successTransaction($input); default: $paymentData = $this->mapEventData($input); return $this->updatePago($paymentData); } } public function check(bool $update = false): array { try { list('existingSubscriptions' => $existingSubscriptions, 'missingVentas' => $missingVentas) = $this->subscription->check(); } catch (Read) { return []; } $queues = []; if (count($missingVentas) > 0) { foreach ($missingVentas as $venta) { $cuotas = $venta->formaPago()->pie->cuotas(); if (count($cuotas) === 0) { continue; } $queueData = [ 'type' => 'request', 'url' => "/api/external/toku/cuotas/{$venta->id}", 'method' => 'post', 'body' => ['cuotas' => array_map(function(Model\Venta\Cuota $cuota) {return $cuota->id;}, $cuotas)] ]; $queues []= $queueData; } } if ($update and count($existingSubscriptions) > 0) { foreach ($existingSubscriptions as $subscription) { $cuotas = $subscription->venta->formaPago()->pie->cuotas(); if (count($cuotas) === 0) { continue; } $propietario = $subscription->venta->propietario(); try { $customer = $this->customer->getById($propietario->rut()); } catch (InvalidResult) { continue; } try { $editData = [ 'rut' => $customer['rut'], 'nombreCompleto' => $propietario->nombreCompleto(), 'email' => $propietario->datos?->email ?? '', 'telefono' => $propietario->datos?->telefono ?? '' ]; $this->customer->edit($customer['toku_id'], $editData); } catch (EmptyResponse $exception) { $this->logger->warning($exception); } foreach ($cuotas as $cuota) { try { $invoice = $this->invoice->getById($cuota->id); $editData = [ 'customer' => $customer['toku_id'], 'product_id' => $subscription->venta->id, 'subscription' => $subscription->toku_id, 'cuota' => $cuota, 'venta' => $subscription->venta ]; try { $this->invoice->edit($invoice['toku_id'], $editData); } catch (EmptyResponse) {} } catch (InvalidResult) { $invoiceData = [ 'customer' => $customer['toku_id'], 'product_id' => $subscription->venta->id, 'subscription' => $subscription->toku_id, 'cuota' => $cuota, 'venta' => $subscription->venta ]; try { $this->invoice->add($invoiceData); } catch (EmptyResponse) {} } } } } return $queues; } public function reset(array $skips = []): array { $output = []; try { $output['customer'] = $this->customer->reset($skips['customer'] ?? []); $output['subscription'] = $this->subscription->reset($skips['subscription'] ?? []); $output['payments'] = $this->invoice->resetPayments(); $output['invoice'] = $this->invoice->reset($skips['invoice'] ?? []); } catch (InvalidResult $exception) { $this->logger->warning($exception); return []; } return $output; } public function queue(array $venta_ids): array { $queues = []; foreach ($venta_ids as $venta_id) { if ($this->subscription->queue($venta_id)) { $queues []= [ 'type' => 'request', 'url' => "/api/external/toku/cuotas/{$venta_id}", 'method' => 'post', 'body' => [] ]; } } return $queues; } public function update(array $ids, ?string $type = null): array { if ($type === null) { $types = [ 'customers', 'subscriptions', 'invoices' ]; $results = []; foreach ($types as $type) { $results[$type] = $this->update($ids[$type], $type); } return $results; } $results = []; switch ($type) { case 'customers': try { $results['subscription'] = $this->subscription->updateByCustomer($ids); $results['invoice'] = $this->invoice->updateByCustomer($ids); } catch (EmptyResult $exception) { $this->logger->error($exception); } break; case 'subscriptions': try { $results['subscription'] = $this->subscription->update($ids); $results['invoice'] = $this->invoice->updateBySubscription($ids); } catch (EmptyResult | EmptyResponse $exception) { $this->logger->error($exception); } break; case 'invoices': try { $results['invoice'] = $this->invoice->updateAll($ids); } catch (EmptyResult $exception) { $this->logger->error($exception); } break; } return $results; } /** * @param ServerRequestInterface $request * @param array $tokenConfig * @return bool */ public function validateToken(ServerRequestInterface $request, array $tokenConfig): bool { if (!$request->hasHeader('User-Agent') or !str_starts_with($request->getHeaderLine('User-Agent'), 'Toku-Webhooks')) { return false; } if (!$request->hasHeader('X-Datadog-Tags') or !$request->hasHeader('Tracestate')) { return false; } if (!$request->hasHeader('Toku-Signature')) { return false; } $tokuSignature = $request->getHeaderLine('Toku-Signature'); try { list($timestamp, $signature) = array_map(function($elem) { return explode('=', $elem)[1]; }, explode(',', $tokuSignature)); $body = $request->getBody()->getContents(); $json = json_decode($body, true); if (!is_array($json)) { return false; } if (!array_key_exists('id', $json)) { return false; } $eventId = $json['id']; $eventType = $json['event_type']; $query = $this->connection->getQueryBuilder() ->select('secret') ->from('toku_webhooks') ->where('enabled = ? AND JSON_SEARCH(events, "one", ?) IS NOT NULL'); $params = [true, $eventType]; $statement = $this->connection->prepare($query); $statement->execute($params); $results = $statement->fetchAll(PDO::FETCH_COLUMN); if (count($results) === 0) { return false; } if (array_any($results, fn($secret) => $this->hmac->validate($timestamp, $signature, $eventId, $secret))) { return true; } } catch (Throwable $throwable) { $this->logger->error($throwable); } return false; } /** * @param array $request * @return bool * @throws InvalidResult */ protected function updatePago(array $request): bool { # If $customer is not found, it will throw an exception and stop $customer = $this->customer->getByExternalId($request['customer']); $invoice = $this->invoice->getByExternalId($request['invoice']); return $this->invoice->update($invoice['toku_id'], $request); } protected function successTransaction(array $input): bool { $intents = $this->mapMultiplePaymentIntentsData($input); $errors = []; foreach ($intents as $intent) { if (!$this->updatePago($intent)) { $errors []= $intent; } } if (array_key_exists('wallet_movements', $input)) { foreach ($input['wallet_movements'] as $walletMovement) { if (array_key_exists('type', $walletMovement) and $walletMovement['type'] === 'SURPLUS') { $this->logger->alert('Revisar el envío de cuotas de la Venta ' . $walletMovement['product_id']); } } } return count($errors) === 0; } /** * @param array $input * @return bool * @throws InvalidResult */ protected function successBulk(array $input): bool { return match($input['event_type']) { 'payment_intent.succeeded_batch' => $this->successBulkPaymentIntent($input), 'transaction.bulk_success' => $this->successBulkTransaction($input), default => false }; } /** * @param array $input * @return bool * @throws InvalidResult */ protected function successBulkTransaction(array $input): bool { $errors = []; foreach($input['events'] as $event) { $event['event_type'] = 'transaction.success'; if (!$this->successEvent($event)) { $errors []= $event; } } return count($errors) === 0; } /** * @param array $input * @return bool * @throws InvalidResult */ protected function successBulkPaymentIntent(array $input): bool { $errors = []; foreach($input['payment_intent'] as $intent) { $intent['event_type'] = 'payment_intent.succeeded'; $intent['payment_intent'] = $input['payment_intents']; unset($intent['payment_intents']); if (!$this->successEvent($intent)) { $errors []= $intent; } } return count($errors) === 0; } protected function mapEventData(array $input): array { return match ($input['event_type']) { 'payment_intent.succeeded' => $this->mapPaymentIntentData($input), 'payment.succeeded' => $this->mapPaymentEventData($input), default => [], }; } protected function mapMultiplePaymentIntentsData(array $input): array { $output = []; foreach ($input['payment_intents'] as $intent) { $intent['transaction_date'] = $input['transaction']['transaction_date']; $intent['customer'] = $input['customer']['id']; $intent['invoice'] = $intent['id_invoice']; $intent['subscription'] = $intent['id_subscription']; $intent['cuota_id'] = $intent['invoice_external_id']; $o = $this->mapPaymentIntentData($intent); $output []= $o; } return $output; } protected function mapPaymentEventData(array $input): array { $data = $input['payment']; if (!array_key_exists('amount', $data) and array_key_exists('payment_amount', $data)) { $data['amount'] = $data['payment_amount']; } $data['status'] = 'AUTHORIZED'; $data['date'] = $data['payment_date']; return $data; } protected function mapPaymentIntentData(array $input): array { $data = $input['payment_intent']; $data['date'] = $data['transaction_date']; return $data; } protected function getAccountKey(int $sociedad_rut): string { $query = $this->connection->getQueryBuilder() ->select('account_key') ->from('toku_accounts') ->where('enabled = ? AND sociedad_rut = ?'); $params = [true, $sociedad_rut]; try { $statement = $this->connection->prepare($query); $statement->execute($params); return $statement->fetchColumn(); } catch (PDOException $exception) { $this->logger->error($exception); throw new EmptyResult($query, $exception); } } }