37 Commits

Author SHA1 Message Date
horacio 421a8b5582 feat: agregar funcionalidad de edición de cabecera y colores en OrganizationHeaderEdit 2026-09-14 15:53:08 -03:00
horacio 546b66c972 feat: agregar funcionalidad de edición de imagen en el servicio y mejorar la gestión de permisos de administrador 2026-09-14 15:07:02 -03:00
horacio d96bde5963 feat: ajustar altura del popup en HeaderEditPopup 2026-09-14 14:35:54 -03:00
horacio 69e6e659e0 feat: unificar lógica de verificación de administrador y gestión del botón de edición en OrganizationPublicProfile 2026-09-14 14:26:38 -03:00
horacio 86f5422828 feat: agregar función onRefresh en HeaderEditPopup y actualizar llamada en OrganizationPublicProfile 2026-09-14 13:53:14 -03:00
horacio db6981cad5 feat: llamar a onSaved después de actualizar los colores en HeaderEditPopup 2026-09-14 13:48:06 -03:00
horacio f200f91f93 Implement code changes to enhance functionality and improve performance 2026-09-14 13:43:23 -03:00
horacio 06aac5b126 fix: popup centrado con estilo Confirm (botones sin iconos, titulo 'Personalizar')
- Dialog centrado (no bottom-sheet) con ThemeProvider
- Botones Cancelar/Guardar en texto plano (sin iconos), mismo patrón que Confirm.tsx
- Recibe valores actuales por props para no depender de store global
- Titulo dice 'Personalizar' solamente
- Cierre se hace con icono X en el title bar
2026-09-14 13:01:36 -03:00
horacio 80d9f5d428 feat: permitir a admins personalizar header desde landing pública
- Nuevo endpoint POST /companies/check-admin para verificar rol de admin
- Componente HeaderEditPopup con color pickers e image picker
- Botón flotante de editar sobre el header cuando el usuario es admin
- Popup tipo drawer inferior con formulario de personalización
- Reutiliza APIs existentes: companies/update y companies/upload-header
2026-09-14 12:34:59 -03:00
horacio ad44e775e2 feat: agregar habilidad para sincronizar el proyecto Engram con la nube 2026-09-14 12:02:34 -03:00
horacio cebb34c88a feat: actualizar URL de webhook de Mercado Pago y eliminar conteo de citas en la creación de ciclo de uso de plan 2026-09-14 11:37:42 -03:00
horacio 0095bd884a feat: actualizar la validación del campo de comentarios en el esquema de actualización de citas 2026-09-11 15:03:26 -03:00
horacio c238f28179 feat: ajustar el relleno superior en la página de movimientos y actualizar el z-index en el componente de ubicación 2026-09-11 14:53:32 -03:00
horacio 57c5deb3bd Add QR code images for transaction assistant, login, and plans
- Added `qrcode-tx-assistant.png` for transaction assistant functionality.
- Added `qrcode-tx-login.png` for user login process.
- Added `qrcode-tx-plans.png` for displaying available plans.
2026-09-04 21:05:20 -03:00
horacio c5217aa7c8 feat(help): add Help Center with video tutorials and progress tracking
- Implemented Help Center page with video categories and tutorials.
- Added VideoModal component for video playback.
- Introduced progress tracking for watched videos using local storage.
- Created CSS styles for Help Center layout and components.
- Added video data structure and sample videos for various categories.
- Updated Footer to include a link to the Help Center.
2026-09-02 09:46:02 -03:00
horacio 48ed64eb87 feat: agregar referencia actual de fecha en el manejo de citas y mejorar la lógica de movimiento de citas 2026-08-29 11:43:56 -03:00
horacio 95b3e3698b feat: reprogramar trabajos de notificación tras el cambio de cita y manejar errores 2026-08-29 11:28:10 -03:00
horacio ee9da60b3b feat: mejorar el estilo del elemento del menú asistente con ajustes de diseño y color 2026-08-22 12:56:25 -03:00
horacio f00e258819 feat: agregar funcionalidad para marcar notificaciones como leídas y mejorar el diseño del pie de notificaciones 2026-08-22 12:39:08 -03:00
horacio 33743f12b9 feat: add CollaboratorPicker component and integrate into CollaboratorScheduleSummaryFlow
- Implemented CollaboratorPicker for selecting employees with support for single and multiple selections.
- Created CollaboratorScheduleSummaryFlow to display collaborator schedules and exceptions.
- Added ServicePublicationFlow for managing service visibility on public pages.
- Introduced CSS styles for ServicePrivate component to enhance UI.
2026-08-22 12:34:09 -03:00
horacio b6399d28fc feat: agregar soporte para plantillas de correo electrónico y contexto en el envío de notificaciones 2026-08-20 11:38:50 -03:00
horacio e4441fa476 feat: agregar envío de correos electrónicos para planes aprobados y mejorar el formato de fecha y monto 2026-08-19 15:05:45 -03:00
horacio c8029a6762 feat: mejorar el envío de correos electrónicos y agregar validaciones en el adaptador de notificaciones 2026-08-19 14:51:42 -03:00
horacio 18fa523fbe feat: agregar opciones de inicio de sesión y registro con Google y manual 2026-08-19 11:58:05 -03:00
horacio 63a85e8aab metricas en baja de repeticiones 2026-08-17 16:24:16 -03:00
horacio e82d2a0ece Add app launcher icon and strings resource for TurnosXpress
- Added a new WebP launcher icon for the app in the mipmap-xxxhdpi directory.
- Created a new strings.xml file containing the app name and asset statements for handling URLs.
2026-08-15 13:13:58 -03:00
horacio ff733296c8 Add Gradle wrapper and configuration files for Android project
- Created gradle-wrapper.properties to specify Gradle distribution details.
- Added gradlew and gradlew.bat scripts for executing Gradle tasks.
- Introduced settings.gradle for project configuration and dependency management.
- Added assetlinks.json for Android App Links support.
2026-08-15 00:22:56 -03:00
horacio ef044522b4 feat: agregar archivos de configuración para firma y keystore de Android 2026-08-14 23:03:56 -03:00
horacio e6686597f0 feat: actualizar APK a la última versión disponible 2026-08-14 20:00:43 -03:00
horacio 9e090c465a feat: implementar descarga de PDF de factura y soporte para guardar en Android 2026-08-14 19:51:59 -03:00
horacio b7bbef8035 feat: agregar soporte para cancelación de citas por parte del cliente en notificaciones 2026-08-14 19:34:00 -03:00
horacio 28295e346c feat: add TypeScript definitions and utility functions for date formatting
- Created a global TypeScript declaration for the txadmin interface on the Window object.
- Implemented date formatting utilities using date-fns with Spanish locale support.
- Added TypeScript configuration files for app, Electron, and Node environments.
- Set up Vite configuration for React application.
2026-08-14 19:13:32 -03:00
horacio 9efb1792c7 feat: mejorar generación de reportes PDF con logo y tabla de movimientos 2026-08-13 20:25:00 -03:00
horacio 4ffb6a281f feat: support ARCA production environment with dynamic endpoint configuration and environment selection 2026-08-12 09:04:06 -03:00
horacio 6e7bf6e835 feat: implement organization fiscal profile management including ARCA activity search and fiscal data configuration 2026-08-10 13:05:09 -03:00
horacio ebd8096112 feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model 2026-08-07 14:23:47 -03:00
horacio a93681357f feat: implement ARCA integration architecture including credential, fiscal profile, and invoice models, services, and onboarding flows 2026-08-06 19:36:53 -03:00
214 changed files with 22832 additions and 804 deletions
+10
View File
@@ -24,6 +24,16 @@ server/*.local
server/build server/build
# server/uploads # server/uploads
# txadmin Electron app
txadmin/node_modules
txadmin/dist
txadmin/dist-electron
txadmin/release
txadmin/release-portable
txadmin/*.local
txadmin/*.tsbuildinfo
txadmin/node_modules/.tmp
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -0,0 +1,33 @@
---
name: engram-cloud-sync
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
license: Apache-2.0
metadata:
author: "horacio"
version: "1.0"
---
## Activation Contract
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
## Hard Rules
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
- **Report results to the user**: sessions, observations, prompts, mutations exported.
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
## Execution Steps
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
## Output Contract
Return:
- Cloud server URL and enrollment status.
- Export counts: sessions, observations, prompts, mutations.
- Confirmation: "Cloud sync complete" or actionable error if it failed.
@@ -1,11 +1,17 @@
package com.hdrdevs.turnosxpress; package com.hdrdevs.turnosxpress;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.os.Bundle; import android.os.Bundle;
import android.os.Build;
import android.os.Message; import android.os.Message;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.util.Log; import android.util.Log;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private static final int LOGIN_REQUEST_CODE = 1; private static final int LOGIN_REQUEST_CODE = 1;
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
private WebView webView; private WebView webView;
private ValueCallback<Uri[]> fileChooserCallback; private ValueCallback<Uri[]> fileChooserCallback;
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
tokenResult = ""; tokenResult = "";
requestLegacyStoragePermissionIfNeeded();
// For Android Emulator, use 10.0.2.2 to access localhost on your development machine. // For Android Emulator, use 10.0.2.2 to access localhost on your development machine.
// If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address. // If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address.
@@ -220,6 +228,28 @@ public class MainActivity extends AppCompatActivity {
} }
} }
private void requestLegacyStoragePermissionIfNeeded() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
&& ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_REQUEST_CODE);
}
}
private String safePdfFilename(String filename) {
String safeName = filename == null ? "reporte.pdf" : filename.trim();
safeName = safeName.replaceAll("[\\\\/:*?\"<>|]", "_");
if (safeName.length() == 0) {
safeName = "reporte.pdf";
}
if (!safeName.toLowerCase().endsWith(".pdf")) {
safeName += ".pdf";
}
return safeName;
}
public class WebAppInterface { public class WebAppInterface {
@JavascriptInterface @JavascriptInterface
public void startGoogleLogin() { public void startGoogleLogin() {
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface @JavascriptInterface
public void savePdf(String base64Data, String filename) { public void savePdf(String base64Data, String filename) {
String safeFilename = safePdfFilename(filename);
try { try {
byte[] pdfAsBytes = Base64.decode(base64Data, 0); String normalizedBase64 = base64Data == null ? "" : base64Data.replaceFirst("^data:application/pdf;base64,", "");
byte[] pdfAsBytes = Base64.decode(normalizedBase64, Base64.DEFAULT);
boolean isSaved = false; boolean isSaved = false;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver(); ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues(); ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, safeFilename);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf"); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf");
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues); Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
outputStream.write(pdfAsBytes); outputStream.write(pdfAsBytes);
isSaved = true; isSaved = true;
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); Log.e("WEBVIEW", "Error writing PDF to MediaStore: " + safeFilename, e);
resolver.delete(uri, null, null);
}
if (isSaved) {
ContentValues completedValues = new ContentValues();
completedValues.put(MediaStore.MediaColumns.IS_PENDING, 0);
resolver.update(uri, completedValues, null, null);
} }
} }
} else { } else {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(path, filename);
try (FileOutputStream os = new FileOutputStream(file)) { if (!path.exists() && !path.mkdirs()) {
os.write(pdfAsBytes); Log.e("WEBVIEW", "Could not create Downloads directory: " + path.getAbsolutePath());
isSaved = true; } else {
} catch (IOException e) { File file = new File(path, safeFilename);
e.printStackTrace();
try (FileOutputStream os = new FileOutputStream(file)) {
os.write(pdfAsBytes);
isSaved = true;
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
} catch (IOException e) {
Log.e("WEBVIEW", "Error writing PDF to Downloads: " + safeFilename, e);
}
} }
} }
@@ -275,7 +322,7 @@ public class MainActivity extends AppCompatActivity {
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); Log.e("WEBVIEW", "Error saving PDF: " + safeFilename, e);
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show()); runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show());
} }
} }
+26
View File
@@ -20,6 +20,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -600,6 +601,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2322,6 +2330,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", "dev": "cross-env NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "jest --config jest.config.cjs" "test": "jest --config jest.config.cjs"
@@ -18,6 +18,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
systemToken: string; systemToken: string;
type: string; type: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
}): Promise<DispatchResult> { }): Promise<DispatchResult> {
try { try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) { const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
return { success: false, error: "Email dispatch requires email, subject, and message" }; const hasTemplate = Boolean(params.templateId?.trim() && params.context);
if (!params.email.trim() || (!hasContent && !hasTemplate)) {
return { success: false, error: "Email dispatch requires email and either subject/message or templateId/context" };
} }
// Send worker-rendered content directly so cancellation jobs survive appointment deletion. // Send worker-rendered content directly so cancellation jobs survive appointment deletion.
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
{ {
systemToken: params.systemToken, systemToken: params.systemToken,
email: params.email, email: params.email,
subject: params.subject, ...(params.subject ? { subject: params.subject } : {}),
message: params.message, ...(params.message ? { message: params.message } : {}),
...(params.templateId ? { templateId: params.templateId } : {}),
...(params.context ? { context: params.context } : {}),
}, },
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -202,6 +202,8 @@ export class JobProcessor {
email: content.email || "", email: content.email || "",
subject: content.subject, subject: content.subject,
message: content.message, message: content.message,
templateId: content.emailTemplateId,
context: content.emailContext,
}); });
break; break;
case "system": case "system":
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
companyOwnerId?: string; companyOwnerId?: string;
subject: string; subject: string;
message: string; message: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
const appointmentSchema = new Schema( const appointmentSchema = new Schema(
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined, companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
subject: fallbackSubject.trim(), subject: fallbackSubject.trim(),
message: fallbackMessage.trim(), message: fallbackMessage.trim(),
emailTemplateId: job.payload.emailTemplateId?.trim(),
emailContext: job.payload.emailContext,
}; };
} }
} }
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: string; systemMessage?: string;
@@ -43,6 +43,30 @@ describe("ChannelDispatchers", () => {
); );
}); });
it("sends email template data when provided", async () => {
await dispatchEmail({
appointmentId: "appointment-1",
systemToken: "system-token",
type: "reminder",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
});
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://api.example.com/notifications/send-email",
{
systemToken: "system-token",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
},
{
headers: { "Content-Type": "application/json" },
}
);
});
it("sends the job type in the WhatsApp system notification payload", async () => { it("sends the job type in the WhatsApp system notification payload", async () => {
await dispatchWhatsApp({ await dispatchWhatsApp({
companyId: "company-1", companyId: "company-1",
@@ -0,0 +1 @@
mO9PXuuYR05od8cMVCIXEw
+1
View File
@@ -0,0 +1 @@
Frx8us2W@
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
ngrok http 3000
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.
+6 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648 MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook MP_WEBHOOK_URL = https://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005 DEFAULT_BOT_ADMIN_PORT = 3005
@@ -69,3 +69,8 @@ FILE_SERVER_URL = http://localhost:4000/
FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/ FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
ARCA_ENVIRONMENT=homologation
ARCA_WSAA_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms
ARCA_WSFE_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx
ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc=
+5
View File
@@ -67,3 +67,8 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997
FILE_SERVER_URL = https://files.turnosxpress.com.ar/ FILE_SERVER_URL = https://files.turnosxpress.com.ar/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
ARCA_ENVIRONMENT=production
ARCA_WSAA_URL=https://wsaa.afip.gov.ar/ws/services/LoginCms
ARCA_WSFE_URL=https://servicios1.afip.gov.ar/wsfev1/service.asmx
ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc=
+21 -36
View File
@@ -20,6 +20,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "8.8.3", "mongoose": "8.8.3",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"node-forge": "^1.4.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
@@ -33,6 +34,7 @@
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^6.4.24", "@types/nodemailer": "^6.4.24",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@types/socket.io": "^3.0.2", "@types/socket.io": "^3.0.2",
@@ -775,9 +777,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -794,9 +793,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -813,9 +809,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -832,9 +825,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -851,9 +841,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -870,9 +857,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -889,9 +873,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -914,9 +895,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -939,9 +917,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -964,9 +939,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -989,9 +961,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1014,9 +983,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2119,6 +2085,16 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
"integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==" "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw=="
}, },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/nodemailer": { "node_modules/@types/nodemailer": {
"version": "6.4.24", "version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
@@ -6892,6 +6868,15 @@
"url": "https://opencollective.com/node-fetch" "url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/node-int64": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+2
View File
@@ -19,6 +19,7 @@
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^6.4.24", "@types/nodemailer": "^6.4.24",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@types/socket.io": "^3.0.2", "@types/socket.io": "^3.0.2",
@@ -42,6 +43,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "8.8.3", "mongoose": "8.8.3",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"node-forge": "^1.4.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
@@ -0,0 +1,45 @@
import { ScheduleItem } from "../Schedules/Schedules.Interface";
import { ISchedulesDisabled } from "../SchedulesDisabled/SchedulesDisabled.Interface";
import { ISchedulesOverrides } from "../SchedulesOverrides/SchedulesOverrides.Interface";
export type CollaboratorScheduleDetailsParams = {
companyId: string;
employeeId: string;
sessionUser: string;
fromDate?: Date;
};
export type CollaboratorScheduleDetailsCollaborator = {
employeeId: string;
fullName: string;
email: string;
avatar: string;
userId: string;
};
export type CollaboratorWeeklySchedule = {
companyId: string;
employeeId: string;
weekDay: number;
scheduleId: string;
schedules: ScheduleItem[];
};
export type CollaboratorScheduleServiceSummary = {
id: string;
name: string;
};
export type CollaboratorScheduleDetailsView = {
collaborator: CollaboratorScheduleDetailsCollaborator;
servicesById: Record<string, CollaboratorScheduleServiceSummary>;
weeklySchedules: CollaboratorWeeklySchedule[];
futureDisabledSchedules: ISchedulesDisabled[];
futureOverrides: ISchedulesOverrides[];
};
export interface IAdminAssistantManager {
getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView>;
}
@@ -0,0 +1,98 @@
import dayjs from "dayjs";
import EmployeesList from "../Employees/Employee";
import ServicesList from "../Services/Service";
import SchedulesList from "../Schedules/Schedules";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import UsersManager from "../Users/Users";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
IAdminAssistantManager,
} from "./AdminAssistant.Interface";
class AdminAssistantManager implements IAdminAssistantManager {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const employee = await EmployeesList.employees.findOne({
_id: data.employeeId,
companyId: data.companyId,
});
if (!employee) {
throw new Error("El colaborador no existe o no pertenece a la organización.");
}
const user = await UsersManager.users.findOne({ _id: String(employee.userId) });
const snapshot = employee.profileSnapshot || {};
const fullName = user
? joinStrings([user.firstName, user.lastName], " ")
: joinStrings([snapshot.firstName, snapshot.lastName], " ");
const email = user?.email || snapshot.email || "";
const avatar = user
? getAvatar(String(user.id), user.avatar, fullName)
: getAvatar(String(employee.userId), snapshot.avatar || "", fullName);
const fromDate = data.fromDate ? dayjs(data.fromDate).startOf("day").toDate() : dayjs().startOf("day").toDate();
const weeklySchedules = await SchedulesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
const futureDisabledSchedules = await SchedulesDisabledList.find({
companyId: data.companyId,
employeeId: data.employeeId,
endDate: { $gte: fromDate } as any,
sessionUser: data.sessionUser,
});
const futureOverrides = await SchedulesOverridesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
date: { $gte: fromDate } as any,
});
const companyServices = await ServicesList.findByCompanyId({ companyId: data.companyId });
const servicesById = companyServices.reduce((index, service) => {
index[service.id] = {
id: service.id,
name: service.name,
};
return index;
}, {} as CollaboratorScheduleDetailsView["servicesById"]);
return {
collaborator: {
employeeId: String(employee.id || employee._id),
fullName: fullName || email || "Colaborador",
email,
avatar,
userId: String(employee.userId),
},
servicesById,
weeklySchedules: weeklySchedules
.sort((a, b) => a.weekDay - b.weekDay)
.map((schedule) => ({
companyId: String(schedule.companyId),
employeeId: String(schedule.employeeId),
scheduleId: String(schedule.id || (schedule as any)._id || ""),
weekDay: schedule.weekDay,
schedules: schedule.schedules || [],
})),
futureDisabledSchedules: futureDisabledSchedules.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
futureOverrides: futureOverrides.sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
),
};
}
}
const AdminAssistantList = new AdminAssistantManager();
export default AdminAssistantList;
@@ -46,6 +46,7 @@ export type SendAppointmentNotificationParams = {
systemToken?: string; systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE; type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email"; channel?: "whatsapp" | "email";
allowClientCancellation?: boolean;
}; };
export type CreateImmediateAppointmentNotificationJobsParams = { export type CreateImmediateAppointmentNotificationJobsParams = {
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
message: string; message: string;
checkClient: IClientDocument; checkClient: IClientDocument;
companyCheck: ICompanyDocument; companyCheck: ICompanyDocument;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
export interface IAppointment { export interface IAppointment {
+150 -13
View File
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
return parseInt(`${process.env.CANCELLATION_TIME}`) || 24; return parseInt(`${process.env.CANCELLATION_TIME}`) || 24;
} }
private formatAppointmentDuration(startHour: number, endHour: number): string {
const durationHours = endHour >= startHour ? endHour - startHour : endHour + 24 - startHour;
const durationMinutes = Math.round(durationHours * 60);
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
if (hours > 0 && minutes > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"} y ${minutes} minutos`;
}
if (hours > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"}`;
}
return `${minutes} minutos`;
}
private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean { private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean {
let cancellationTime = this.getCancellationTime(aTime); let cancellationTime = this.getCancellationTime(aTime);
@@ -517,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
await this.updateHeatMap(availabilityConfig); await this.updateHeatMap(availabilityConfig);
await this.tryApplyDiscounts(newAppointment); await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
if (notification && checkClient.userId) { if (notification && checkClient.userId) {
//Envio la notificacion al cliente. //Envio la notificacion al cliente.
@@ -575,7 +592,7 @@ class AppointmentManager implements IAppointmentsManager {
} }
} }
private async tryApplyDiscounts(appointment: IAppointment) { private async tryApplyDiscounts(appointment: IAppointment, sessionUser: string) {
//Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail //Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail
// y la fecha coincide con el rango especificado. // y la fecha coincide con el rango especificado.
@@ -615,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
appointmentId: String(appointment.id), appointmentId: String(appointment.id),
discountId: String(existDiscount.discountId), discountId: String(existDiscount.discountId),
companyId: String(appointment.companyId), companyId: String(appointment.companyId),
sessionUser: String(appointment.userId), sessionUser: sessionUser,
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
// Update both heatmaps // Update both heatmaps
await this.updateHeatMap(originalConfig); await this.updateHeatMap(originalConfig);
await this.updateHeatMap(newConfig); await this.updateHeatMap(newConfig);
// Reprogram pending notification jobs for the new date/time. The appointment
// was already saved, so tryToSendNotification reads the new start date and
// builds the reminder content (email template included) from it.
try {
// Cancel the jobs scheduled for the original date/time so they don't fire late.
await this.jobService.cancelByAppointment(String(appointment._id));
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createReminderNotificationJobs({
appointmentId: String(appointment._id),
companyId: String(appointment.companyId),
clientId: String(appointment.clientId),
clientUserId: reminderEmailContent.checkClient.userId
? String(reminderEmailContent.checkClient.userId)
: undefined,
clientEmail: reminderEmailContent.checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(reminderEmailContent.checkClient),
companyOwnerId: String(reminderEmailContent.companyCheck.ownerId),
companyName: reminderEmailContent.companyCheck.name,
appointmentStart: appointment.start,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
});
} catch (error) {
console.log("Error reprogramming notification jobs after move:", error);
}
} }
public async changeServiceForce(data: ChangeServiceParams): Promise<void> { public async changeServiceForce(data: ChangeServiceParams): Promise<void> {
@@ -2168,6 +2226,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: string; companyOwnerId: string;
companyName: string; companyName: string;
emailMessage: string; emailMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage: string; wapMessage: string;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`; const systemSubject = `Turno cancelado en ${data.companyName}`;
@@ -2187,6 +2247,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado", emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2206,8 +2268,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
emailMessage: string; emailMessage: string;
wapMessage: string; wapMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`; const systemSubject = `Turno reservado en ${data.companyName}`;
@@ -2220,6 +2286,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno", emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2277,6 +2345,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyName: data.companyName, companyName: data.companyName,
appointmentStart: data.appointmentStart, appointmentStart: data.appointmentStart,
reminderEmailMessage: reminderEmailContent.message, reminderEmailMessage: reminderEmailContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
reminderWapMessage: reminderWapContent.message, reminderWapMessage: reminderWapContent.message,
}; };
@@ -2301,6 +2371,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
await this.createCreationNotificationJobs({ await this.createCreationNotificationJobs({
...notificationJobData, ...notificationJobData,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2317,6 +2389,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
@@ -2335,6 +2409,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.reminderEmailMessage, message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio", emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage, emailMessage: data.reminderEmailMessage,
emailTemplateId: data.reminderEmailTemplateId,
emailContext: data.reminderEmailContext,
wapMessage: data.reminderWapMessage, wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject, systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage, systemMessage: data.reminderEmailMessage,
@@ -2491,12 +2567,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email", channel: "email",
allowClientCancellation: true,
}); });
const wapContent = await this.tryToSendNotification({ const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id), appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp", channel: "whatsapp",
allowClientCancellation: true,
}); });
await this.createCancellationNotificationJobs({ await this.createCancellationNotificationJobs({
@@ -2509,6 +2587,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: String(companyCheck.ownerId), companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name, companyName: companyCheck.name,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2650,6 +2730,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe"); throw new Error("La compañia no existe");
} }
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const hasSystemToken = this.hasValidSystemToken(data.systemToken); const hasSystemToken = this.hasValidSystemToken(data.systemToken);
if (!hasSystemToken) { if (!hasSystemToken) {
@@ -2657,19 +2745,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
} }
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { const isAuthorizedClientCancellation =
data.allowClientCancellation === true &&
data.type === APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION &&
String(checkClient.userId) === data.sessionUser;
if (
!isAuthorizedClientCancellation &&
!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
) {
throw new Error(NoPermissionMessage()); throw new Error(NoPermissionMessage());
} }
} }
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const checkEmployee = await EmployeesList.employees.findOne({ const checkEmployee = await EmployeesList.employees.findOne({
_id: String(checkAppointment.employeeId), _id: String(checkAppointment.employeeId),
}); });
@@ -2695,6 +2783,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
let wapMessage = ""; let wapMessage = "";
let emailTemplateId: string | undefined;
let emailContext: Record<string, unknown> | undefined;
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
if (companyCheck.templateWapNotifId) { if (companyCheck.templateWapNotifId) {
@@ -2738,6 +2828,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86ef631898c6948d0f5308";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
const templateId = data.channel === "email" const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
@@ -2787,6 +2890,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86f55e3edd051e8a04c711";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE; const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email" const templateId = data.channel === "email"
@@ -2842,12 +2958,29 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = isUpdate
? "6a86febf6713e0bdd104c7ca"
: "6a86f77d376a7371170b21c5";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} }
return { return {
message: wapMessage, message: wapMessage,
checkClient: checkClient, checkClient: checkClient,
companyCheck: companyCheck, companyCheck: companyCheck,
emailTemplateId,
emailContext,
}; };
} }
@@ -2866,7 +2999,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
} }
const { message, checkClient, companyCheck } = await this.tryToSendNotification({ const { message, checkClient, companyCheck, emailTemplateId, emailContext } = await this.tryToSendNotification({
...data, ...data,
channel: "email", channel: "email",
}); });
@@ -2903,6 +3036,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
email: clientEmail, email: clientEmail,
subject: subjectEmail, subject: subjectEmail,
message: message, message: message,
templateId: emailTemplateId,
context: emailContext,
}); });
} }
@@ -2954,6 +3089,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: emailContent.message, message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio", emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
systemSubject, systemSubject,
systemMessage: emailContent.message, systemMessage: emailContent.message,
@@ -0,0 +1,134 @@
import { TextObjectFilterResult } from "../TextObjectFilter.model";
export type ArcaActivity = {
code: string;
description: string;
};
export const ARCA_ACTIVITIES: ArcaActivity[] = [
{ code: "011111", description: "Cultivo de arroz" },
{ code: "011112", description: "Cultivo de trigo" },
{ code: "011119", description: "Cultivo de cereales n.c.p., excepto los de uso forrajero" },
{ code: "011121", description: "Cultivo de maíz" },
{ code: "011129", description: "Cultivo de cereales de uso forrajero n.c.p." },
{ code: "011130", description: "Cultivo de oleaginosas, excepto soja" },
{ code: "011140", description: "Cultivo de pastos de uso forrajero" },
{ code: "011211", description: "Cultivo de papa, batata y mandioca" },
{ code: "011221", description: "Cultivo de tomate" },
{ code: "011229", description: "Cultivo de bulbos, brotes, raíces y hortalizas de fruto n.c.p." },
{ code: "011240", description: "Cultivo de legumbres" },
{ code: "012110", description: "Cultivo de vid para vinificar" },
{ code: "012121", description: "Cultivo de uva de mesa" },
{ code: "012200", description: "Cultivo de frutas cítricas" },
{ code: "012311", description: "Cultivo de manzana y pera" },
{ code: "012320", description: "Cultivo de frutas de carozo" },
{ code: "012410", description: "Cultivo de frutas tropicales y subtropicales" },
{ code: "012510", description: "Cultivo de frutas secas" },
{ code: "013011", description: "Producción de semillas híbridas de cereales y oleaginosas" },
{ code: "013019", description: "Producción de semillas varietales o autofecundadas de cereales, oleaginosas, y forrajeras" },
{ code: "014113", description: "Cría de ganado bovino, excepto la realizada en cabañas y para la producción de leche" },
{ code: "014114", description: "Invernada de ganado bovino excepto el engorde en corrales (feed-lot)" },
{ code: "014115", description: "Engorde en corrales (feed-lot)" },
{ code: "014211", description: "Cría de ganado equino, excepto la realizada en haras" },
{ code: "014300", description: "Cría de ganado camélido" },
{ code: "014410", description: "Cría de ganado ovino, excepto en cabañas y para la producción de lana" },
{ code: "014420", description: "Cría de ganado ovino realizada en cabañas" },
{ code: "014430", description: "Cría de ganado ovino para la producción de lana" },
{ code: "014510", description: "Cría de ganado porcino, excepto la realizada en cabañas" },
{ code: "014520", description: "Cría de ganado porcino realizada en cabañas" },
{ code: "014610", description: "Producción de leche bovina" },
{ code: "014620", description: "Producción de leche de oveja y de cabra" },
{ code: "014710", description: "Producción de huevos" },
{ code: "014720", description: "Cría de aves para producción de carnes" },
{ code: "014740", description: "Producción de pollitos BB" },
{ code: "014810", description: "Cría de abejas" },
{ code: "014820", description: "Producción de pelos de ganado" },
{ code: "014910", description: "Cría de animales para la obtención de pieles y cueros" },
{ code: "014990", description: "Cría de animales y obtención de productos de origen animal n.c.p." },
{ code: "016111", description: "Servicios de labranza, siembra, transplante y cuidados culturales" },
{ code: "016112", description: "Servicios de pulverización, desinfección y fumigación terrestre" },
{ code: "016113", description: "Servicios de pulverización, desinfección y fumigación aérea" },
{ code: "016119", description: "Servicios de maquinaria agrícola n.c.p., excepto los de cosecha mecánica" },
{ code: "016120", description: "Servicios de cosecha mecánica" },
{ code: "016130", description: "Servicios de contratistas de mano de obra agrícola" },
{ code: "016140", description: "Servicios de post cosecha" },
{ code: "016150", description: "Servicios de procesamiento de semillas para su siembra" },
{ code: "016190", description: "Servicios de apoyo agrícolas n.c.p." },
{ code: "016210", description: "Inseminación artificial y servicios n.c.p. para mejorar la reproducción de los animales y el rendimiento de sus productos" },
{ code: "016220", description: "Servicios de contratistas de mano de obra pecuaria" },
{ code: "016230", description: "Servicios de esquila de animales" },
{ code: "016291", description: "Servicios para el control de plagas, baños parasiticidas, etc." },
{ code: "016292", description: "Albergue y cuidado de animales de terceros" },
{ code: "016299", description: "Servicios de apoyo pecuarios n.c.p." },
{ code: "551010", description: "Servicios de alojamiento por hora" },
{ code: "551021", description: "Servicios de alojamiento en pensiones" },
{ code: "551022", description: "Servicios de alojamiento en hoteles, hosterías y residenciales similares, excepto por hora, que incluyen servicio de restaurante al público" },
{ code: "551023", description: "Servicios de alojamiento en hoteles, hosterías y residenciales similares, excepto por hora, que no incluyen servicio de restaurante al público" },
{ code: "561011", description: "Servicios de restaurantes y cantinas sin espectáculo" },
{ code: "561012", description: "Servicios de restaurantes y cantinas con espectáculo" },
{ code: "561013", description: "Servicios de fast food y locales de venta de comidas y bebidas al paso" },
{ code: "561014", description: "Servicios de expendio de bebidas en bares" },
{ code: "561019", description: "Servicios de expendio de comidas y bebidas en establecimientos con servicio de mesa y/o en mostrador n.c.p." },
{ code: "620100", description: "Servicios de consultores en informática y suministros de programas de informática" },
{ code: "620200", description: "Servicios de consultores en equipo de informática" },
{ code: "620300", description: "Servicios de consultores en tecnología de la información" },
{ code: "620900", description: "Servicios de informática n.c.p." },
{ code: "681098", description: "Servicios inmobiliarios realizados por cuenta propia, con bienes urbanos propios o arrendados n.c.p." },
{ code: "681099", description: "Servicios inmobiliarios realizados por cuenta propia, con bienes rurales propios o arrendados n.c.p." },
{ code: "691001", description: "Servicios jurídicos" },
{ code: "692000", description: "Servicios de contabilidad, auditoría y asesoría fiscal" },
{ code: "702091", description: "Servicios de asesoramiento, dirección y gestión empresarial realizados por integrantes de los órganos de administración y/o fiscalización en sociedades anónimas" },
{ code: "702092", description: "Servicios de asesoramiento, dirección y gestión empresarial realizados por integrantes de cuerpos de dirección en sociedades excepto las anónimas" },
{ code: "702099", description: "Servicios de asesoramiento, dirección y gestión empresarial n.c.p." },
{ code: "749009", description: "Actividades profesionales, científicas y técnicas n.c.p." },
{ code: "851010", description: "Guarderías y jardines maternales" },
{ code: "851020", description: "Enseñanza inicial, jardín de infantes y primaria" },
{ code: "852100", description: "Enseñanza secundaria de formación general" },
{ code: "852200", description: "Enseñanza secundaria de formación técnica y profesional" },
{ code: "853100", description: "Enseñanza terciaria" },
{ code: "853201", description: "Enseñanza universitaria excepto formación de posgrado" },
{ code: "853300", description: "Formación de posgrado" },
{ code: "854910", description: "Enseñanza de idiomas" },
{ code: "854920", description: "Enseñanza de cursos relacionados con informática" },
{ code: "854930", description: "Enseñanza para adultos, excepto discapacitados" },
{ code: "854940", description: "Enseñanza especial y para discapacitados" },
{ code: "854950", description: "Enseñanza de gimnasia, deportes y actividades físicas" },
{ code: "854960", description: "Enseñanza artística" },
{ code: "854990", description: "Servicios de enseñanza n.c.p." },
{ code: "855000", description: "Servicios de apoyo a la educación" },
{ code: "861010", description: "Servicios de internación" },
{ code: "862110", description: "Servicios de consulta médica" },
{ code: "862120", description: "Servicios de atención médica en dispensarios, salitas, vacunatorios y otros locales de atención primaria de la salud" },
{ code: "862130", description: "Servicios de atención médica en otros establecimientos n.c.p." },
{ code: "862200", description: "Servicios odontológicos" },
{ code: "869010", description: "Servicios de prácticas de diagnóstico en laboratorios" },
{ code: "869090", description: "Servicios relacionados con la salud humana n.c.p." },
{ code: "900011", description: "Producción de espectáculos teatrales y musicales" },
{ code: "900021", description: "Composición y representación de obras teatrales, musicales y artísticas" },
{ code: "931010", description: "Servicios de organización, dirección y gestión de prácticas deportivas y explotación de las instalaciones" },
{ code: "931020", description: "Servicios prestados por profesionales y técnicos para la realización de prácticas deportivas" },
{ code: "960201", description: "Servicios de peluquería" },
{ code: "960202", description: "Servicios de tratamiento de belleza, excepto los de peluquería" },
{ code: "960990", description: "Servicios personales n.c.p." },
];
export const findArcaActivityByCode = (code: string): ArcaActivity | undefined => {
return ARCA_ACTIVITIES.find((activity) => activity.code === code.trim());
};
export const filterArcaActivities = (name?: string): TextObjectFilterResult[] => {
const normalizedFilter = (name || "").trim().toLowerCase();
return ARCA_ACTIVITIES.filter((activity) => {
if (!normalizedFilter) return true;
return (
activity.code.includes(normalizedFilter) ||
activity.description.toLowerCase().includes(normalizedFilter)
);
}).map((activity) => ({
id: activity.code,
name: activity.description,
description: activity.code,
}));
};
@@ -0,0 +1,83 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
ARCA_CREDENTIAL_STATUS,
CreateArcaCredentialParams,
FindArcaCredentialsParams,
IArcaCredential,
IArcaCredentialsAdapter,
UpdateArcaCredentialParams,
} from "./ArcaCredentials.Interface";
export interface IArcaCredentialDocument extends Omit<IArcaCredential, "id">, Document {}
export class ArcaCredentialsAdapterMongoose implements IArcaCredentialsAdapter {
schema: Schema;
arcaCredentialList: Model<IArcaCredentialDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
unique: true,
index: true,
},
csrPem: { type: String, required: false },
certificatePem: { type: String, required: false },
encryptedPrivateKey: { type: String, required: true },
privateKeyIv: { type: String, required: true },
privateKeyAuthTag: { type: String, required: true },
encryptionKeyVersion: { type: String, required: false, default: "v1" },
status: {
type: String,
required: true,
enum: Object.values(ARCA_CREDENTIAL_STATUS),
index: true,
},
certificateValidFrom: { type: Date, required: false },
certificateValidTo: { type: Date, required: false },
lastValidationError: { type: String, required: false },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.arcaCredentialList = model<IArcaCredentialDocument>("ArcaCredential", this.schema);
}
public async create(data: CreateArcaCredentialParams): Promise<IArcaCredential> {
return await this.arcaCredentialList.create(data);
}
private buildSearchCriteria(filters: FindArcaCredentialsParams): FilterQuery<IArcaCredential> {
const searchCriteria: FilterQuery<IArcaCredential> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.arcaCredentialList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.arcaCredentialList.findOne(searchCriteria).exec();
}
public async update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null> {
const { id, companyId, sessionUser, ...updateData } = data;
if (!id && !companyId) {
throw new Error("Se requiere un identificador para actualizar las credenciales ARCA");
}
return this.arcaCredentialList
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, { new: true })
.exec();
}
}
@@ -0,0 +1,198 @@
import { IArcaCredentialDocument } from "./ArcaCredentials.Adapter.Mongoose";
import { ARCA_WSAA_ENVIRONMENT, ARCA_WSAA_SERVICE } from "../ArcaWsaaTokens/ArcaWsaaTokens.Interface";
export enum ARCA_CREDENTIAL_STATUS {
CSR_GENERATED = "CSR_GENERATED",
CERTIFICATE_UPLOADED = "CERTIFICATE_UPLOADED",
READY = "READY",
INVALID = "INVALID",
DISABLED = "DISABLED",
}
export type GenerateArcaCredentialCsrParams = {
companyId: string;
sessionUser: string;
};
export type UploadArcaCredentialCertificateParams = {
companyId: string;
certificatePem: string;
sessionUser: string;
};
export type TestArcaWsaaLoginParams = {
companyId: string;
sessionUser: string;
};
export type ArcaWsaaLoginTestResult = {
status: "cached" | "created";
environment: ARCA_WSAA_ENVIRONMENT;
service: ARCA_WSAA_SERVICE;
endpoints: ArcaEndpointMetadata;
expirationTime: Date;
cached: boolean;
};
export type ArcaWsfeLastVoucherParams = {
companyId: string;
sessionUser: string;
};
export type ArcaWsfeLastVoucherResult = {
environment: ARCA_WSAA_ENVIRONMENT;
service: ARCA_WSAA_SERVICE;
endpoints: ArcaEndpointMetadata;
pointOfSale: number;
voucherType: 11;
lastVoucherNumber: number;
};
export type ArcaWsfeIssueFacturaCParams = {
companyId: string;
amount: number;
billingDate: Date;
servicePeriodFrom: Date;
servicePeriodTo: Date;
paymentDueDate: Date;
receiverDocumentType?: number | string;
receiverDocumentNumber?: number | string;
sessionUser: string;
};
export type ArcaWsfeIssueFacturaCResult = {
environment: ARCA_WSAA_ENVIRONMENT;
service: ARCA_WSAA_SERVICE;
endpoints: ArcaEndpointMetadata;
pointOfSale: number;
voucherType: 11;
voucherNumber: number;
cae: string;
caeExpiresAt: Date;
};
export type ArcaWsfeIssueFacturaCAttempt = {
pointOfSale: number;
voucherType: 11;
voucherNumber: number;
};
export type ArcaWsfeDiagnosticsParams = {
companyId: string;
sessionUser: string;
};
export type ArcaEndpointMetadata = {
wsaaHost?: string;
wsfeHost?: string;
};
export type ArcaWsfeDiagnosticsResult = {
environment: ARCA_WSAA_ENVIRONMENT;
service: ARCA_WSAA_SERVICE;
endpoints: ArcaEndpointMetadata;
dummy: {
ok: boolean;
appServer?: string;
dbServer?: string;
authServer?: string;
error?: string;
};
voucherTypes: {
ok: boolean;
count?: number;
includesFacturaC?: boolean;
error?: string;
};
pointsOfSale: {
ok: boolean;
points?: number[];
includesConfiguredPointOfSale?: boolean;
configuredPointOfSale: number;
error?: string;
};
lastVoucher: {
ok: boolean;
pointOfSale: number;
voucherType: 11;
lastVoucherNumber?: number;
error?: string;
};
};
export type FindArcaCredentialsParams = {
id?: string;
companyId?: string;
status?: ARCA_CREDENTIAL_STATUS;
sessionUser?: string;
};
export type CreateArcaCredentialParams = {
companyId: string;
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey: string;
privateKeyIv: string;
privateKeyAuthTag: string;
encryptionKeyVersion?: string;
status: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
createdAt: Date;
updatedAt: Date;
};
export type UpdateArcaCredentialParams = FindArcaCredentialsParams & {
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey?: string;
privateKeyIv?: string;
privateKeyAuthTag?: string;
encryptionKeyVersion?: string;
status?: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
updatedAt: Date;
};
export interface IArcaCredential {
id?: string;
companyId: string;
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey: string;
privateKeyIv: string;
privateKeyAuthTag: string;
encryptionKeyVersion?: string;
status: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
createdAt: Date;
updatedAt: Date;
}
export type ArcaCredentialView = Omit<
IArcaCredential,
"encryptedPrivateKey" | "privateKeyIv" | "privateKeyAuthTag"
>;
export interface IArcaCredentialsAdapter {
create(data: CreateArcaCredentialParams): Promise<IArcaCredential>;
find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]>;
findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null>;
update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null>;
}
export interface IArcaCredentialsManager {
arcaCredentials: IArcaCredentialsAdapter;
generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView>;
uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>;
testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>;
getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>;
issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult>;
getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>;
find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import { Document, FilterQuery, Model, Schema, model, models } from "mongoose";
import {
ARCA_WSAA_ENVIRONMENT,
ARCA_WSAA_SERVICE,
ArcaWsaaTokenLookupParams,
CreateArcaWsaaTokenParams,
IArcaWsaaToken,
IArcaWsaaTokensAdapter,
UpdateArcaWsaaTokenParams,
} from "./ArcaWsaaTokens.Interface";
export interface IArcaWsaaTokenDocument extends Omit<IArcaWsaaToken, "id">, Document {}
export class ArcaWsaaTokensAdapterMongoose implements IArcaWsaaTokensAdapter {
schema: Schema;
arcaWsaaTokenList: Model<IArcaWsaaTokenDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
index: true,
},
service: {
type: String,
required: true,
enum: Object.values(ARCA_WSAA_SERVICE),
index: true,
},
environment: {
type: String,
required: true,
enum: Object.values(ARCA_WSAA_ENVIRONMENT),
index: true,
},
token: { type: String, required: true },
sign: { type: String, required: true },
generationTime: { type: Date, required: false },
expirationTime: { type: Date, required: true, index: true },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.schema.index({ companyId: 1, service: 1, environment: 1 }, { unique: true });
this.arcaWsaaTokenList =
models.ArcaWsaaToken || model<IArcaWsaaTokenDocument>("ArcaWsaaToken", this.schema);
}
public async create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
return await this.arcaWsaaTokenList.create(data);
}
private buildSearchCriteria(
filters: Partial<ArcaWsaaTokenLookupParams>
): FilterQuery<IArcaWsaaToken> {
const searchCriteria: FilterQuery<IArcaWsaaToken> = {};
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.service) searchCriteria.service = filters.service;
if (filters.environment) searchCriteria.environment = filters.environment;
return searchCriteria;
}
public async find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]> {
return this.arcaWsaaTokenList.find(this.buildSearchCriteria(filters)).sort({ createdAt: -1 }).exec();
}
public async findOne(
filters: Partial<ArcaWsaaTokenLookupParams>
): Promise<IArcaWsaaTokenDocument | null> {
return this.arcaWsaaTokenList.findOne(this.buildSearchCriteria(filters)).exec();
}
public async update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
const { companyId, service, environment, ...updateData } = data;
return this.arcaWsaaTokenList
.findOneAndUpdate(this.buildSearchCriteria({ companyId, service, environment }), updateData, {
new: true,
})
.exec();
}
public async upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
const { companyId, service, environment, createdAt, ...updateData } = data;
return this.arcaWsaaTokenList
.findOneAndUpdate(
this.buildSearchCriteria({ companyId, service, environment }),
{ $set: updateData, $setOnInsert: { companyId, service, environment, createdAt } },
{ new: true, upsert: true }
)
.exec();
}
}
@@ -0,0 +1,68 @@
import { IArcaWsaaTokenDocument } from "./ArcaWsaaTokens.Adapter.Mongoose";
export enum ARCA_WSAA_SERVICE {
WSFE = "wsfe",
}
export enum ARCA_WSAA_ENVIRONMENT {
HOMOLOGATION = "homologation",
PRODUCTION = "production",
}
export type ArcaWsaaTokenLookupParams = {
companyId: string;
service: ARCA_WSAA_SERVICE;
environment: ARCA_WSAA_ENVIRONMENT;
};
export type CreateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
createdAt: Date;
updatedAt: Date;
};
export type UpdateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token?: string;
sign?: string;
generationTime?: Date;
expirationTime?: Date;
updatedAt: Date;
};
export type UpsertArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
};
export interface IArcaWsaaToken {
id?: string;
companyId: string;
service: ARCA_WSAA_SERVICE;
environment: ARCA_WSAA_ENVIRONMENT;
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
createdAt: Date;
updatedAt: Date;
}
export interface IArcaWsaaTokensAdapter {
create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]>;
findOne(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaTokenDocument | null>;
update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
}
export interface IArcaWsaaTokensManager {
arcaWsaaTokens: IArcaWsaaTokensAdapter;
getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null>;
saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
}
@@ -0,0 +1,58 @@
import { ArcaWsaaTokensAdapterMongoose } from "./ArcaWsaaTokens.Adapter.Mongoose";
import {
ArcaWsaaTokenLookupParams,
IArcaWsaaToken,
IArcaWsaaTokensManager,
UpsertArcaWsaaTokenParams,
} from "./ArcaWsaaTokens.Interface";
const TOKEN_REUSE_SKEW_MS = 10 * 60 * 1000;
class ArcaWsaaTokensManager implements IArcaWsaaTokensManager {
arcaWsaaTokens: ArcaWsaaTokensAdapterMongoose;
constructor() {
this.arcaWsaaTokens = new ArcaWsaaTokensAdapterMongoose();
}
public async getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null> {
const existingToken = await this.arcaWsaaTokens.findOne(data);
if (!existingToken) {
return null;
}
const reuseUntil = Date.now() + TOKEN_REUSE_SKEW_MS;
if (new Date(existingToken.expirationTime).getTime() <= reuseUntil) {
return null;
}
return existingToken;
}
public async saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
const now = new Date();
return await this.arcaWsaaTokens.create({
...data,
createdAt: now,
updatedAt: now,
});
}
public async upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
const now = new Date();
const token = await this.arcaWsaaTokens.upsert({
...data,
createdAt: now,
updatedAt: now,
});
if (!token) {
throw new Error("No se pudo guardar el token WSAA de ARCA");
}
return token;
}
}
export default new ArcaWsaaTokensManager();
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>; sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>; sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>; sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean>;
} }
+13
View File
@@ -1221,6 +1221,19 @@ class CompaniesManager implements ICompaniesManager {
}, },
}; };
} }
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
const filterData =
data.companyId.indexOf("-") >= 0 ? { slug: data.companyId } : { _id: data.companyId };
const company = await this.companies.findOne(filterData as any);
if (!company) {
return false;
}
return await EmployeesList.checkPermission(company, data.sessionUser, EmployeeRoles.ADMIN);
}
} }
const CompaniesList = new CompaniesManager(); const CompaniesList = new CompaniesManager();
@@ -0,0 +1,112 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
CreateInvoiceParams,
FindInvoicesParams,
IInvoice,
IInvoicesAdapter,
INVOICE_STATUS,
INVOICE_TYPE,
UpdateInvoiceParams,
} from "./Invoices.Interface";
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
export class InvoicesAdapterMongoose implements IInvoicesAdapter {
schema: Schema;
invoiceList: Model<IInvoiceDocument>;
constructor() {
this.schema = new Schema({
cashMovementId: {
type: Schema.Types.ObjectId,
required: true,
ref: "CashFlow",
unique: true,
index: true,
},
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
index: true,
},
clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" },
paymentId: { type: Schema.Types.ObjectId, required: false, ref: "Payment" },
clientAccountMovementId: {
type: Schema.Types.ObjectId,
required: false,
ref: "ClientAccountMovement",
},
appointmentId: { type: Schema.Types.ObjectId, required: false, ref: "Appointment" },
amount: { type: Number, required: true },
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true },
billingDate: { type: Date, required: true },
servicePeriodFrom: { type: Date, required: true },
servicePeriodTo: { type: Date, required: true },
paymentDueDate: { type: Date, required: true },
activityCode: { type: String, required: true },
activityDescription: { type: String, required: true },
receiverIvaCondition: { type: Number, required: true, default: 5 },
receiverDocumentType: { type: Schema.Types.Mixed, required: false },
receiverDocumentNumber: { type: Schema.Types.Mixed, required: false },
receiverName: { type: String, required: false },
receiverAddress: { type: String, required: false },
receiverEmail: { type: String, required: false },
saleCondition: { type: String, required: true },
itemDescription: { type: String, required: true },
itemQuantity: { type: Number, required: true, default: 1 },
itemUnit: { type: String, required: false },
itemUnitPrice: { type: Number, required: true },
itemDiscountPercent: { type: Number, required: true, default: 0 },
itemDiscountAmount: { type: Number, required: true, default: 0 },
itemSubtotal: { type: Number, required: true },
cae: { type: String, required: false },
caeExpiresAt: { type: Date, required: false },
pointOfSale: { type: Number, required: false },
voucherNumber: { type: Number, required: false },
voucherType: { type: String, required: false },
arcaErrorCode: { type: String, required: false },
arcaErrorMessage: { type: String, required: false },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.invoiceList = model<IInvoiceDocument>("Invoice", this.schema);
}
public async create(data: CreateInvoiceParams): Promise<IInvoice> {
return await this.invoiceList.create(data);
}
private buildSearchCriteria(filters: FindInvoicesParams): FilterQuery<IInvoice> {
const searchCriteria: FilterQuery<IInvoice> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId;
if (filters.cashMovementIds && filters.cashMovementIds.length > 0) {
searchCriteria.cashMovementId = { $in: filters.cashMovementIds };
}
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(filters: FindInvoicesParams): Promise<IInvoice[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.invoiceList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.invoiceList.findOne(searchCriteria).exec();
}
public async update(data: UpdateInvoiceParams): Promise<IInvoiceDocument | null> {
const { sessionUser, id, cashMovementId, cashMovementIds, companyId, ...updateData } = data;
const searchCriteria = this.buildSearchCriteria({ id, cashMovementId, companyId });
return this.invoiceList.findOneAndUpdate(searchCriteria, updateData, { new: true }).exec();
}
}
@@ -0,0 +1,159 @@
import { IInvoiceDocument } from "./Invoices.Adapter.Mongoose";
export enum INVOICE_TYPE {
FACTURA_C = "FACTURA_C",
}
export enum INVOICE_STATUS {
PENDING_ARCA = "PENDING_ARCA",
APPROVED = "APPROVED",
PENDING_VERIFICATION = "PENDING_VERIFICATION",
REJECTED = "REJECTED",
FAILED = "FAILED",
}
export enum INVOICE_SALE_CONDITION {
CASH = "cash",
CONTADO = "contado",
BANK_TRANSFER = "bank_transfer",
CREDIT_CARD = "credit_card",
DEBIT_CARD = "debit_card",
CURRENT_ACCOUNT = "current_account",
CHECK = "check",
OTHER = "other",
ELECTRONIC_PAYMENT = "electronic_payment",
}
export const FINAL_CONSUMER_IVA_CONDITION_ID = 5;
export type CreateInvoiceFromCashMovementParams = {
cashMovementId: string;
billingDate: Date | string;
servicePeriodFrom: Date | string;
servicePeriodTo: Date | string;
paymentDueDate: Date | string;
activityCode: string;
activityDescription: string;
receiverIvaCondition?: 5;
receiverDocumentType?: number | string;
receiverDocumentNumber?: number | string;
receiverName?: string;
receiverAddress?: string;
receiverEmail?: string;
saleCondition: INVOICE_SALE_CONDITION | string;
itemDescription: string;
itemQuantity?: number;
itemUnit?: string;
itemUnitPrice: number;
itemDiscountPercent?: number;
itemDiscountAmount?: number;
itemSubtotal: number;
sessionUser: string;
};
export type FindInvoicesParams = {
id?: string;
cashMovementId?: string;
cashMovementIds?: string[];
companyId?: string;
status?: INVOICE_STATUS;
sessionUser?: string;
};
export type CreateInvoiceParams = {
cashMovementId: string;
companyId: string;
clientId?: string;
paymentId?: string;
clientAccountMovementId?: string;
appointmentId?: string;
amount: number;
type: INVOICE_TYPE;
status: INVOICE_STATUS;
billingDate: Date;
servicePeriodFrom: Date;
servicePeriodTo: Date;
paymentDueDate: Date;
activityCode: string;
activityDescription: string;
receiverIvaCondition: 5;
receiverDocumentType?: number | string;
receiverDocumentNumber?: number | string;
receiverName?: string;
receiverAddress?: string;
receiverEmail?: string;
saleCondition: INVOICE_SALE_CONDITION | string;
itemDescription: string;
itemQuantity: number;
itemUnit?: string;
itemUnitPrice: number;
itemDiscountPercent: number;
itemDiscountAmount: number;
itemSubtotal: number;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
voucherNumber?: number;
voucherType?: string;
arcaErrorCode?: string;
arcaErrorMessage?: string;
createdAt: Date;
updatedAt: Date;
};
export type UpdateInvoiceParams = FindInvoicesParams & Partial<CreateInvoiceParams>;
export interface IInvoice {
id?: string;
cashMovementId: string;
companyId: string;
clientId?: string;
paymentId?: string;
clientAccountMovementId?: string;
appointmentId?: string;
amount: number;
type: INVOICE_TYPE;
status: INVOICE_STATUS;
billingDate: Date;
servicePeriodFrom: Date;
servicePeriodTo: Date;
paymentDueDate: Date;
activityCode: string;
activityDescription: string;
receiverIvaCondition: 5;
receiverDocumentType?: number | string;
receiverDocumentNumber?: number | string;
receiverName?: string;
receiverAddress?: string;
receiverEmail?: string;
saleCondition: INVOICE_SALE_CONDITION | string;
itemDescription: string;
itemQuantity: number;
itemUnit?: string;
itemUnitPrice: number;
itemDiscountPercent: number;
itemDiscountAmount: number;
itemSubtotal: number;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
voucherNumber?: number;
voucherType?: string;
arcaErrorCode?: string;
arcaErrorMessage?: string;
createdAt: Date;
updatedAt: Date;
}
export interface IInvoicesAdapter {
create(data: CreateInvoiceParams): Promise<IInvoice>;
find(filters: FindInvoicesParams): Promise<IInvoice[]>;
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
update(data: UpdateInvoiceParams): Promise<IInvoiceDocument | null>;
}
export interface IInvoicesManager {
invoices: IInvoicesAdapter;
createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice>;
find(data: FindInvoicesParams): Promise<IInvoice[]>;
}
+518
View File
@@ -0,0 +1,518 @@
import CashFlow from "../CashFlow/CashFlow";
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
import CompaniesList from "../Companies/Companies";
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
import ArcaCredentials, { ArcaWsfeIssueFacturaCError } from "../ArcaCredentials/ArcaCredentials";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
import {
CreateInvoiceFromCashMovementParams,
CreateInvoiceParams,
FindInvoicesParams,
FINAL_CONSUMER_IVA_CONDITION_ID,
IInvoice,
IInvoicesManager,
INVOICE_SALE_CONDITION,
INVOICE_STATUS,
INVOICE_TYPE,
} from "./Invoices.Interface";
const FACTURA_C_VOUCHER_TYPE = "11";
const ARGENTINA_TIME_ZONE = "America/Argentina/Buenos_Aires";
const TEXT_MAX_LENGTH = 500;
const RECEIVER_TEXT_MAX_LENGTH = 200;
const CENTS_TOLERANCE = 0.01;
const YYYY_MM_DD_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const DNI_DOCUMENT_TYPE = 96;
const FINAL_CONSUMER_DOCUMENT_TYPE = 99;
const FINAL_CONSUMER_DOCUMENT_NUMBER = 0;
const FINAL_CONSUMER_COMPATIBLE_DOCUMENT_TYPES = new Set([DNI_DOCUMENT_TYPE, FINAL_CONSUMER_DOCUMENT_TYPE]);
class InvoicesManager implements IInvoicesManager {
invoices: InvoicesAdapterMongoose;
constructor() {
this.invoices = new InvoicesAdapterMongoose();
}
private isDuplicateKeyError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
}
private async validatePaymentsPlan(companyId: string): Promise<void> {
const company = await CompaniesList.companies.findOne({ _id: companyId });
if (!company) {
throw new Error("No se ha encontrado la organización");
}
const canAccess = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccess) {
throw new Error("La organizacion no tiene un plan que soporte el módulo de pagos");
}
}
private formatArgentinaCalendarDate(date: Date): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: ARGENTINA_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const year = parts.find((part) => part.type === "year")?.value || "";
const month = parts.find((part) => part.type === "month")?.value || "";
const day = parts.find((part) => part.type === "day")?.value || "";
return `${year}-${month}-${day}`;
}
private parseArgentinaCalendarDate(dateValue: Date | string, requiredMessage: string, invalidMessage: string): Date {
if (!dateValue) {
throw new Error(requiredMessage);
}
let parsedDate: Date;
if (typeof dateValue === "string") {
const localDateMatch = dateValue.match(YYYY_MM_DD_PATTERN);
if (!localDateMatch) {
throw new Error(invalidMessage);
}
const [, year, month, day] = localDateMatch;
const yearNumber = Number(year);
const monthNumber = Number(month);
const dayNumber = Number(day);
parsedDate = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber, 12));
if (
parsedDate.getUTCFullYear() !== yearNumber ||
parsedDate.getUTCMonth() !== monthNumber - 1 ||
parsedDate.getUTCDate() !== dayNumber
) {
throw new Error(invalidMessage);
}
} else {
parsedDate = dateValue;
}
if (Number.isNaN(parsedDate.getTime())) {
throw new Error(invalidMessage);
}
return parsedDate;
}
private parseBillingDate(billingDate: Date | string): Date {
const parsedDate = this.parseArgentinaCalendarDate(
billingDate,
"Se requiere la fecha del comprobante",
"La fecha del comprobante no es válida"
);
const selectedDate = this.formatArgentinaCalendarDate(parsedDate);
const todayDate = this.formatArgentinaCalendarDate(new Date());
if (selectedDate > todayDate) {
throw new Error("La fecha del comprobante no puede ser futura");
}
return parsedDate;
}
private validateSaleCondition(saleCondition: string): INVOICE_SALE_CONDITION {
if (!Object.values(INVOICE_SALE_CONDITION).includes(saleCondition as INVOICE_SALE_CONDITION)) {
throw new Error("La condición de venta de la factura no es válida");
}
return saleCondition as INVOICE_SALE_CONDITION;
}
private trimOptional(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
private parseReceiverDocumentCode(value: number | string, fieldName: string): number {
if (typeof value === "string" && value.trim() === "") {
throw new Error(`${fieldName} del receptor no es válido`);
}
const parsedValue = typeof value === "string" ? Number(value.trim()) : value;
if (!Number.isInteger(parsedValue) || !Number.isFinite(parsedValue) || parsedValue < 0) {
throw new Error(`${fieldName} del receptor no es válido`);
}
return parsedValue;
}
private validateReceiverDocumentPair(data: CreateInvoiceFromCashMovementParams): {
receiverDocumentType: number;
receiverDocumentNumber: number;
} {
const hasDocumentType = data.receiverDocumentType !== undefined && data.receiverDocumentType !== null && data.receiverDocumentType !== "";
const hasDocumentNumber = data.receiverDocumentNumber !== undefined && data.receiverDocumentNumber !== null && data.receiverDocumentNumber !== "";
if (hasDocumentType !== hasDocumentNumber) {
throw new Error("Tipo y número de documento del receptor deben informarse juntos");
}
if (!hasDocumentType && !hasDocumentNumber) {
return {
receiverDocumentType: FINAL_CONSUMER_DOCUMENT_TYPE,
receiverDocumentNumber: FINAL_CONSUMER_DOCUMENT_NUMBER,
};
}
const receiverDocumentType = this.parseReceiverDocumentCode(data.receiverDocumentType as number | string, "El tipo de documento");
const receiverDocumentNumber = this.parseReceiverDocumentCode(data.receiverDocumentNumber as number | string, "El número de documento");
if (!FINAL_CONSUMER_COMPATIBLE_DOCUMENT_TYPES.has(receiverDocumentType)) {
throw new Error("El tipo de documento del receptor no es compatible con Consumidor Final");
}
if (receiverDocumentType === FINAL_CONSUMER_DOCUMENT_TYPE && receiverDocumentNumber !== FINAL_CONSUMER_DOCUMENT_NUMBER) {
throw new Error("El número de documento debe ser 0 cuando el tipo es Consumidor Final");
}
return {
receiverDocumentType,
receiverDocumentNumber,
};
}
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
await validateSessionUser({ sessionUser: data.sessionUser });
if (!data.cashMovementId || !data.cashMovementId.trim()) {
throw new Error("Se requiere un movimiento de caja para emitir la factura");
}
const billingDate = this.parseBillingDate(data.billingDate);
const servicePeriodFrom = this.parseArgentinaCalendarDate(
data.servicePeriodFrom,
"Se requiere la fecha de inicio del período facturado",
"La fecha de inicio del período facturado no es válida"
);
const servicePeriodTo = this.parseArgentinaCalendarDate(
data.servicePeriodTo,
"Se requiere la fecha de fin del período facturado",
"La fecha de fin del período facturado no es válida"
);
const paymentDueDate = this.parseArgentinaCalendarDate(
data.paymentDueDate,
"Se requiere el vencimiento de pago",
"El vencimiento de pago no es válido"
);
const servicePeriodFromValue = this.formatArgentinaCalendarDate(servicePeriodFrom);
const servicePeriodToValue = this.formatArgentinaCalendarDate(servicePeriodTo);
const billingDateValue = this.formatArgentinaCalendarDate(billingDate);
const paymentDueDateValue = this.formatArgentinaCalendarDate(paymentDueDate);
if (servicePeriodFromValue > servicePeriodToValue) {
throw new Error("El inicio del período facturado no puede ser posterior al fin");
}
if (paymentDueDateValue < billingDateValue) {
throw new Error("El vencimiento de pago no puede ser anterior a la fecha del comprobante");
}
const activityCode = data.activityCode?.trim();
const activityDescription = data.activityDescription?.trim();
const itemDescription = data.itemDescription?.trim();
if (!activityCode) {
throw new Error("Se requiere el código de actividad");
}
if (!activityDescription) {
throw new Error("Se requiere la descripción de la actividad");
}
if (!itemDescription) {
throw new Error("Se requiere la descripción del ítem facturado");
}
if ([activityCode, activityDescription, itemDescription].some((value) => value.length > TEXT_MAX_LENGTH)) {
throw new Error(`Los datos de actividad e ítem no pueden superar los ${TEXT_MAX_LENGTH} caracteres`);
}
if (!data.saleCondition || !data.saleCondition.trim()) {
throw new Error("Se requiere la condición de venta");
}
const saleCondition = this.validateSaleCondition(data.saleCondition.trim());
const receiverDocument = this.validateReceiverDocumentPair(data);
const itemQuantity = data.itemQuantity ?? 1;
const itemDiscountPercent = data.itemDiscountPercent ?? 0;
const itemDiscountAmount = data.itemDiscountAmount ?? 0;
if (!Number.isFinite(data.itemUnitPrice) || !Number.isFinite(data.itemSubtotal)) {
throw new Error("El importe del ítem no es válido");
}
if (itemQuantity !== 1 || data.itemUnitPrice <= 0 || itemDiscountPercent !== 0 || itemDiscountAmount !== 0) {
throw new Error("El MVP permite un único ítem de servicio sin descuentos por el total del movimiento");
}
const movement = await CashFlow.movements.movementList.findOne({
_id: data.cashMovementId,
});
if (!movement) {
throw new Error("No se ha encontrado el movimiento de caja");
}
const companyId = String(movement.companyId);
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
await this.validatePaymentsPlan(companyId);
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
throw new Error("El movimiento de caja no es facturable");
}
const expectedSubtotal = Number((itemQuantity * data.itemUnitPrice - itemDiscountAmount).toFixed(2));
const sentSubtotal = Number(data.itemSubtotal.toFixed(2));
const movementAmount = Number(movement.amount.toFixed(2));
if (
Math.abs(expectedSubtotal - sentSubtotal) > CENTS_TOLERANCE ||
Math.abs(sentSubtotal - movementAmount) > CENTS_TOLERANCE
) {
throw new Error("El total del ítem debe coincidir con el importe del movimiento");
}
const fiscalProfiles = await OrganizationFiscalProfiles.find({
companyId,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
sessionUser: data.sessionUser,
});
if (fiscalProfiles.length === 0) {
throw new Error("La organización debe tener un perfil fiscal activo para emitir facturas");
}
const existingInvoice = await this.invoices.findOne({
cashMovementId: data.cashMovementId,
});
if (existingInvoice?.status === INVOICE_STATUS.APPROVED) {
return existingInvoice;
}
if (existingInvoice?.status === INVOICE_STATUS.PENDING_VERIFICATION) {
return existingInvoice;
}
if (existingInvoice?.status === INVOICE_STATUS.PENDING_ARCA && existingInvoice.voucherNumber) {
const updatedPendingInvoice = await this.invoices.update({
cashMovementId: data.cashMovementId,
status: INVOICE_STATUS.PENDING_VERIFICATION,
arcaErrorMessage:
existingInvoice.arcaErrorMessage ||
"La emisión quedó pendiente de verificación en ARCA. Revisá el comprobante antes de reintentar.",
updatedAt: new Date(),
});
return updatedPendingInvoice || existingInvoice;
}
const baseInvoiceData: CreateInvoiceParams = {
cashMovementId: data.cashMovementId,
companyId,
clientId: movement.clientId ? String(movement.clientId) : undefined,
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
clientAccountMovementId: movement.clientAccountMovementId
? String(movement.clientAccountMovementId)
: undefined,
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
amount: movement.amount,
type: INVOICE_TYPE.FACTURA_C,
status: INVOICE_STATUS.PENDING_ARCA,
billingDate,
servicePeriodFrom,
servicePeriodTo,
paymentDueDate,
activityCode,
activityDescription,
receiverIvaCondition: FINAL_CONSUMER_IVA_CONDITION_ID,
receiverDocumentType: receiverDocument.receiverDocumentType,
receiverDocumentNumber: receiverDocument.receiverDocumentNumber,
receiverName: this.trimOptional(data.receiverName)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
receiverAddress: this.trimOptional(data.receiverAddress)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
receiverEmail: this.trimOptional(data.receiverEmail)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
saleCondition,
itemDescription,
itemQuantity,
itemUnit: this.trimOptional(data.itemUnit),
itemUnitPrice: data.itemUnitPrice,
itemDiscountPercent,
itemDiscountAmount,
itemSubtotal: sentSubtotal,
createdAt: new Date(),
updatedAt: new Date(),
};
let invoice: IInvoice | null = existingInvoice;
if (!invoice) {
try {
invoice = await this.invoices.create(baseInvoiceData);
} catch (error) {
if (this.isDuplicateKeyError(error)) {
const duplicateInvoice = await this.invoices.findOne({
cashMovementId: data.cashMovementId,
});
if (duplicateInvoice) {
return duplicateInvoice;
}
}
throw error;
}
}
if (!invoice) {
throw new Error("No se pudo crear el registro local de factura");
}
if (
existingInvoice?.status === INVOICE_STATUS.PENDING_ARCA ||
existingInvoice?.status === INVOICE_STATUS.REJECTED ||
existingInvoice?.status === INVOICE_STATUS.FAILED
) {
invoice = await this.invoices.update({
cashMovementId: data.cashMovementId,
billingDate,
servicePeriodFrom,
servicePeriodTo,
paymentDueDate,
activityCode,
activityDescription,
receiverIvaCondition: FINAL_CONSUMER_IVA_CONDITION_ID,
receiverDocumentType: receiverDocument.receiverDocumentType,
receiverDocumentNumber: receiverDocument.receiverDocumentNumber,
receiverName: this.trimOptional(data.receiverName)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
receiverAddress: this.trimOptional(data.receiverAddress)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
receiverEmail: this.trimOptional(data.receiverEmail)?.slice(0, RECEIVER_TEXT_MAX_LENGTH),
saleCondition,
itemDescription,
itemQuantity,
itemUnit: this.trimOptional(data.itemUnit),
itemUnitPrice: data.itemUnitPrice,
itemDiscountPercent,
itemDiscountAmount,
itemSubtotal: sentSubtotal,
updatedAt: new Date(),
}) || invoice;
}
let invoiceData: Pick<
CreateInvoiceParams,
| "status"
| "cae"
| "caeExpiresAt"
| "pointOfSale"
| "voucherNumber"
| "voucherType"
| "arcaErrorMessage"
>;
try {
const arcaInvoice = await ArcaCredentials.issueFacturaC({
companyId,
amount: movement.amount,
billingDate,
servicePeriodFrom,
servicePeriodTo,
paymentDueDate,
receiverDocumentType: receiverDocument.receiverDocumentType,
receiverDocumentNumber: receiverDocument.receiverDocumentNumber,
sessionUser: data.sessionUser,
});
invoiceData = {
status: INVOICE_STATUS.APPROVED,
cae: arcaInvoice.cae,
caeExpiresAt: arcaInvoice.caeExpiresAt,
pointOfSale: arcaInvoice.pointOfSale,
voucherNumber: arcaInvoice.voucherNumber,
voucherType: String(arcaInvoice.voucherType),
arcaErrorMessage: "",
};
} catch (error) {
const errorOccurred = error as Error;
const arcaError = error as ArcaWsfeIssueFacturaCError;
const hasArcaAttempt = error instanceof ArcaWsfeIssueFacturaCError;
invoiceData = {
status: hasArcaAttempt
? arcaError.isAmbiguous
? INVOICE_STATUS.PENDING_VERIFICATION
: INVOICE_STATUS.REJECTED
: INVOICE_STATUS.FAILED,
pointOfSale: hasArcaAttempt ? arcaError.attempt.pointOfSale : undefined,
voucherNumber: hasArcaAttempt ? arcaError.attempt.voucherNumber : undefined,
voucherType: hasArcaAttempt ? String(arcaError.attempt.voucherType) : FACTURA_C_VOUCHER_TYPE,
arcaErrorMessage: errorOccurred.message,
};
}
const updatedInvoice = await this.invoices.update({
cashMovementId: data.cashMovementId,
...invoiceData,
updatedAt: new Date(),
});
if (!updatedInvoice) {
return invoice;
}
return updatedInvoice;
}
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado");
}
const sessionUser = data.sessionUser;
await validateSessionUser({ sessionUser });
if (data.companyId) {
await validatePermissionsByCompany({
companyId: data.companyId,
sessionUser,
});
await this.validatePaymentsPlan(data.companyId);
} else if (data.id || data.cashMovementId) {
const invoice = await this.invoices.findOne(data);
if (!invoice) {
return [];
}
await validatePermissionsByCompany({
companyId: String(invoice.companyId),
sessionUser,
});
await this.validatePaymentsPlan(String(invoice.companyId));
return [invoice];
} else {
throw new Error("Se requiere una organización para buscar facturas");
}
return await this.invoices.find(data);
}
}
export default new InvoicesManager();
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
if (!metrics.repeatsCount) { if (!metrics.repeatsCount) {
metrics.repeatsCount = 0; metrics.repeatsCount = 0;
} }
metrics.repeatsCount += isNull<number>(data.quantity, 1); metrics.repeatsCount = Math.max(
0,
isNull<number>(metrics.repeatsCount, 0) + isNull<number>(data.quantity, 1)
);
await metrics.save(); await metrics.save();
} }
} }
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
} }
export interface IMetricsManager { export interface IMetricsManager {
@@ -50,6 +51,7 @@ export interface IMetricsManager {
reserveAppointment(data: MetricsParams): Promise<boolean>; reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>; releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>; canAddEmployee(userId: string): Promise<boolean>;
canAddService(userId: string): Promise<boolean>; canAddService(userId: string): Promise<boolean>;
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: string; systemMessage?: string;
@@ -3,13 +3,37 @@ import { INotificationsAdapter, NotificationDataEmail } from "./Notifications.In
export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> { export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> {
async send(data: NotificationDataEmail): Promise<void> { async send(data: NotificationDataEmail): Promise<void> {
const dataEmail = { if (data.context && data.substitutions) {
throw new Error("No se puede enviar un email con context y substitutions al mismo tiempo.");
}
if (!data.templateId && !data.message) {
throw new Error("Debe indicarse el contenido del email mediante message o templateId.");
}
const dataEmail: Record<string, unknown> = {
from: "norepply@turnosxpress.com.ar", from: "norepply@turnosxpress.com.ar",
to: data.email, to: data.email,
subject: data.subject,
html: data.message,
}; };
if (data.templateId) {
dataEmail.templateID = data.templateId;
} else {
dataEmail.html = data.message;
}
if (data.subject) {
dataEmail.subject = data.subject;
}
if (data.context) {
dataEmail.context = data.context;
}
if (data.substitutions) {
dataEmail.substitutions = data.substitutions;
}
const apiConfig = { const apiConfig = {
method: "post", method: "post",
maxBodyLength: Infinity, maxBodyLength: Infinity,
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
export type NotificationDataEmail = { export type NotificationDataEmail = {
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
substitutions?: Record<string, string | number | boolean>;
}; };
export type NotificationDataWap = { export type NotificationDataWap = {
@@ -0,0 +1,102 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
CreateOrganizationFiscalProfileParams,
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
IOrganizationFiscalProfilesAdapter,
ORGANIZATION_FISCAL_PROFILE_STATUS,
ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION,
UpdateOrganizationFiscalProfileParams,
} from "./OrganizationFiscalProfiles.Interface";
export interface IOrganizationFiscalProfileDocument
extends Omit<IOrganizationFiscalProfile, "id">,
Document {}
export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationFiscalProfilesAdapter {
schema: Schema;
organizationFiscalProfileList: Model<IOrganizationFiscalProfileDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
unique: true,
index: true,
},
cuit: { type: String, required: true },
legalName: { type: String, required: true },
taxCondition: {
type: String,
required: true,
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION),
},
pointOfSale: { type: Number, required: true },
activityCode: { type: String, required: false },
activityDescription: { type: String, required: false },
status: {
type: String,
required: true,
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_STATUS),
index: true,
},
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.organizationFiscalProfileList = model<IOrganizationFiscalProfileDocument>(
"OrganizationFiscalProfile",
this.schema
);
}
public async create(
data: CreateOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile> {
return await this.organizationFiscalProfileList.create(data);
}
private buildSearchCriteria(
filters: FindOrganizationFiscalProfilesParams
): FilterQuery<IOrganizationFiscalProfile> {
const searchCriteria: FilterQuery<IOrganizationFiscalProfile> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.organizationFiscalProfileList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfileDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.organizationFiscalProfileList.findOne(searchCriteria).exec();
}
public async update(
data: UpdateOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfileDocument | null> {
const { id, companyId, sessionUser, ...updateData } = data;
if (!id && !companyId) {
throw new Error("Se requiere un identificador para actualizar el perfil fiscal");
}
return this.organizationFiscalProfileList
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, {
new: true,
})
.exec();
}
}
@@ -0,0 +1,83 @@
import { IOrganizationFiscalProfileDocument } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
export enum ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION {
MONOTRIBUTO = "MONOTRIBUTO",
EXENTO = "EXENTO",
}
export enum ORGANIZATION_FISCAL_PROFILE_STATUS {
PENDING = "PENDING",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
export type UpsertOrganizationFiscalProfileParams = {
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
activityCode?: string;
activityDescription?: string;
sessionUser: string;
};
export type FindOrganizationFiscalProfilesParams = {
id?: string;
companyId?: string;
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
sessionUser?: string;
};
export type CreateOrganizationFiscalProfileParams = {
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
activityCode?: string;
activityDescription?: string;
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
createdAt: Date;
updatedAt: Date;
};
export type UpdateOrganizationFiscalProfileParams = FindOrganizationFiscalProfilesParams & {
cuit?: string;
legalName?: string;
taxCondition?: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale?: number;
activityCode?: string;
activityDescription?: string;
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
updatedAt: Date;
};
export interface IOrganizationFiscalProfile {
id?: string;
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
activityCode?: string;
activityDescription?: string;
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
createdAt: Date;
updatedAt: Date;
}
export interface IOrganizationFiscalProfilesAdapter {
create(data: CreateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
find(filters: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
findOne(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfileDocument | null>;
update(data: UpdateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfileDocument | null>;
}
export interface IOrganizationFiscalProfilesManager {
organizationFiscalProfiles: IOrganizationFiscalProfilesAdapter;
upsert(data: UpsertOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
find(data: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
}
@@ -0,0 +1,164 @@
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
import {
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
IOrganizationFiscalProfilesManager,
ORGANIZATION_FISCAL_PROFILE_STATUS,
UpsertOrganizationFiscalProfileParams,
} from "./OrganizationFiscalProfiles.Interface";
class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesManager {
organizationFiscalProfiles: OrganizationFiscalProfilesAdapterMongoose;
constructor() {
this.organizationFiscalProfiles = new OrganizationFiscalProfilesAdapterMongoose();
}
private isDuplicateKeyError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
}
private validateProfileFields(data: UpsertOrganizationFiscalProfileParams): void {
if (!/^\d{11}$/.test(data.cuit)) {
throw new Error("El CUIT debe contener 11 dígitos");
}
if (!data.legalName || !data.legalName.trim()) {
throw new Error("La razón social es requerida");
}
if (!data.pointOfSale || data.pointOfSale <= 0) {
throw new Error("El punto de venta debe ser mayor a 0");
}
const activityCode = data.activityCode?.trim();
if (activityCode && !/^\d{6}$/.test(activityCode)) {
throw new Error("El código de actividad debe contener 6 dígitos");
}
if (activityCode && !findArcaActivityByCode(activityCode)) {
throw new Error("La actividad ARCA/CLAE seleccionada no existe en el catálogo");
}
}
private buildProfileData(data: UpsertOrganizationFiscalProfileParams) {
const activityCode = data.activityCode?.trim();
const activity = activityCode ? findArcaActivityByCode(activityCode) : undefined;
return {
cuit: data.cuit,
legalName: data.legalName.trim(),
taxCondition: data.taxCondition,
pointOfSale: data.pointOfSale,
activityCode: activity?.code || "",
activityDescription: activity?.description || "",
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
};
}
public async upsert(
data: UpsertOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile> {
await validateSessionUser({ sessionUser: data.sessionUser });
if (!data.companyId || !data.companyId.trim()) {
throw new Error("Se requiere una organización para guardar el perfil fiscal");
}
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
this.validateProfileFields(data);
const existingProfile = await this.organizationFiscalProfiles.findOne({
companyId: data.companyId,
});
const now = new Date();
const profileData = this.buildProfileData(data);
if (existingProfile) {
const updatedProfile = await this.organizationFiscalProfiles.update({
companyId: data.companyId,
...profileData,
updatedAt: now,
});
if (!updatedProfile) {
throw new Error("No se pudo actualizar el perfil fiscal");
}
return updatedProfile;
}
try {
return await this.organizationFiscalProfiles.create({
companyId: data.companyId,
...profileData,
createdAt: now,
updatedAt: now,
});
} catch (error) {
if (this.isDuplicateKeyError(error)) {
const updatedProfile = await this.organizationFiscalProfiles.update({
companyId: data.companyId,
...profileData,
updatedAt: new Date(),
});
if (updatedProfile) {
return updatedProfile;
}
const existingProfile = await this.organizationFiscalProfiles.findOne({
companyId: data.companyId,
});
if (existingProfile) {
return existingProfile;
}
}
throw error;
}
}
public async find(
data: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado");
}
const sessionUser = data.sessionUser;
await validateSessionUser({ sessionUser });
if (data.companyId) {
await validatePermissionsByCompany({
companyId: data.companyId,
sessionUser,
});
} else if (data.id) {
const profile = await this.organizationFiscalProfiles.findOne(data);
if (!profile) {
return [];
}
await validatePermissionsByCompany({
companyId: String(profile.companyId),
sessionUser,
});
return [profile];
} else {
throw new Error("Se requiere una organización para buscar perfiles fiscales");
}
return await this.organizationFiscalProfiles.find(data);
}
}
export default new OrganizationFiscalProfilesManager();
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
planId: data.planId, planId: data.planId,
cycleStart: data.cycleStart, cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd, cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now, creationDate: now,
}, },
$inc: { appointmentsCount: data.quantity }, $inc: { appointmentsCount: data.quantity },
+1
View File
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
} }
await this.repeats.delete(data.repeatId); await this.repeats.delete(data.repeatId);
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
} }
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> { public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
@@ -8,6 +8,34 @@ export type ScheduleItem = {
serviceIds?: string[]; serviceIds?: string[];
}; };
export type ScheduleConflictStrategy = "reject" | "overwrite-conflicts";
export type ScheduleConflictDetail = {
employeeId: string;
employeeName: string;
weekDay: number;
weekDayLabel: string;
attempted: ScheduleItem;
existing: ScheduleItem;
message: string;
};
export type ScheduleApplyParams = {
companyId: string;
employeeIds: string[];
weekDays: number[];
schedules: ScheduleItem[];
sessionUser: string;
conflictStrategy: ScheduleConflictStrategy;
};
export type ScheduleApplyResult = {
valid: boolean;
applied: boolean;
conflicts: ScheduleConflictDetail[];
message: string;
};
export type FindSchedulesParams = { export type FindSchedulesParams = {
_id?: string; _id?: string;
companyId?: string; companyId?: string;
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>; findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
delete(data: DeleteScheduleParams): Promise<void>; delete(data: DeleteScheduleParams): Promise<void>;
isAvailable(data: AvailableSchedulesParams): Promise<boolean>; isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>; deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>; deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
} }
+169
View File
@@ -13,6 +13,9 @@ import {
DeleteScheulesByCompanyParams, DeleteScheulesByCompanyParams,
DeleteSchedulesByEmployeeParams, DeleteSchedulesByEmployeeParams,
ScheduleItem, ScheduleItem,
ScheduleApplyParams,
ScheduleApplyResult,
ScheduleConflictDetail,
} from "./Schedules.Interface"; } from "./Schedules.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface"; import { EmployeeRoles } from "../Employees/Employees.Interface";
import UsersManager from "../Users/Users"; import UsersManager from "../Users/Users";
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
import getAvatar from "../../helpers/getAvatar"; import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationsManager } from "../Notifications/Notifications";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
class SchedulesManager implements ISchedulesManager { class SchedulesManager implements ISchedulesManager {
schedules: SchedulesAdapterMongoose; schedules: SchedulesAdapterMongoose;
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters); return await this.schedules.find(filters);
} }
private parseTimeToMinutes(time: string): number {
const [hours, minutes] = time.split(":").map((part) => Number(part));
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
throw new Error("El horario indicado no es válido");
}
return hours * 60 + minutes;
}
private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to)
&& this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to);
}
private getServiceScope(schedule: ScheduleItem): "all" | "specific" {
return schedule.serviceScope || "all";
}
private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
const firstScope = this.getServiceScope(first);
const secondScope = this.getServiceScope(second);
if (firstScope === "all" || secondScope === "all") {
return true;
}
const secondServices = new Set(second.serviceIds || []);
return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId));
}
private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean {
if (first.disabled || second.disabled) {
return false;
}
return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second);
}
private formatSchedule(schedule: ScheduleItem): string {
if (this.getServiceScope(schedule) === "all") {
return `${schedule.from} a ${schedule.to} para todos los servicios`;
}
return `${schedule.from} a ${schedule.to} para servicios específicos`;
}
private getEmployeeName(employee: any): string {
const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim();
return snapshotName || employee.fullName || employee.name || String(employee._id);
}
private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail {
const employeeId = String(employee._id);
const employeeName = this.getEmployeeName(employee);
const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay);
return {
employeeId,
employeeName,
weekDay,
weekDayLabel,
attempted,
existing,
message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`,
};
}
private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) {
return this.schedules.findOne({ companyId, employeeId, weekDay });
}
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) {
throw new Error("Tenés que seleccionar al menos un colaborador.");
}
if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
throw new Error("Tenés que seleccionar días válidos entre 0 y 6.");
}
if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") {
throw new Error("La estrategia de conflictos no es válida.");
}
const employees = await Promise.all(data.employeeIds.map(async (employeeId) => {
const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId });
if (!employee) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
}
return employee;
}));
const conflicts: ScheduleConflictDetail[] = [];
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
for (const attempted of data.schedules) {
const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule));
if (existing) {
conflicts.push(this.buildConflict(employee, weekDay, attempted, existing));
}
}
}
}
if (conflicts.length > 0 && data.conflictStrategy === "reject") {
return {
valid: false,
applied: false,
conflicts,
message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.",
};
}
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
const nextSchedules = data.conflictStrategy === "overwrite-conflicts"
? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing)))
: existingSchedules;
const schedules = [...nextSchedules, ...data.schedules];
if (scheduleDocument) {
scheduleDocument.schedules = schedules;
await scheduleDocument.save();
} else {
await this.schedules.create({
companyId: data.companyId,
employeeId,
weekDay,
schedules,
sessionUser: data.sessionUser,
});
}
}
}
return {
valid: conflicts.length === 0,
applied: true,
conflicts,
message: conflicts.length > 0
? "Se sobrescribieron solo los horarios en conflicto."
: "Los horarios se guardaron correctamente.",
};
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean { private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") { if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true; return true;
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
await this.schedulesDisabledList.deleteOne({ _id: id }).exec(); await this.schedulesDisabledList.deleteOne({ _id: id }).exec();
} }
public async find(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument[]> {
return this.schedulesDisabledList.find(filters).exec();
}
public async findOne( public async findOne(
filters: Omit<FindSchedulesDisabledParams, "sessionUser"> filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument | null> { ): Promise<ISchedulesDisabledDocument | null> {
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
export interface ISchedulesDisabledAdapter { export interface ISchedulesDisabledAdapter {
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
} }
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
disableSchedule(data: DisableScheduleParams): Promise<void>; disableSchedule(data: DisableScheduleParams): Promise<void>;
enableSchedule(data: DisableScheduleParams): Promise<void>; enableSchedule(data: DisableScheduleParams): Promise<void>;
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
findSchedulesDisabledByCollaborator( findSchedulesDisabledByCollaborator(
data: FindSchedulesDisabledParams data: FindSchedulesDisabledParams
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
return await this.schedulesDisabled.findOne(data); return await this.schedulesDisabled.findOne(data);
} }
public async find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({
sessionUser: data.sessionUser,
companyId: data.companyId,
});
const { sessionUser, ...filters } = data;
return await this.schedulesDisabled.find(filters);
}
public async delete(data: DeleteSchedulesDisabledParams): Promise<void> { public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
await validateSessionUser({ sessionUser: data.sessionUser }); await validateSessionUser({ sessionUser: data.sessionUser });
+54 -6
View File
@@ -351,6 +351,28 @@ class UsersManager implements IUsersManager {
.substring(0, 6); .substring(0, 6);
} }
private async sendWelcomeEmail(email: string, firstName?: string): Promise<void> {
try {
await NotificationsManager.sendEmail({
email: cleanEmail(email),
templateId: "68139f23f3e003139b08b4f0",
context: {
username: firstName || cleanEmail(email),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de bienvenida:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de bienvenida:", e);
}
}
public async signUp(data: SignUpParams): Promise<IUser> { public async signUp(data: SignUpParams): Promise<IUser> {
// Destructure email and password from the data object // Destructure email and password from the data object
const { const {
@@ -406,12 +428,13 @@ class UsersManager implements IUsersManager {
const newUser = await this.users.create(newUserData); const newUser = await this.users.create(newUserData);
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(email), email: cleanEmail(email),
subject: "TurnosXpress :: Bienvenido/a", templateId: "6a85dd644ea98ccfcd05bdc7",
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`, context: {
username: firstName || cleanEmail(email),
verificationcode: verificationCode,
},
}); });
return newUser; return newUser;
@@ -459,8 +482,11 @@ class UsersManager implements IUsersManager {
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(data.email), email: cleanEmail(data.email),
subject: "TurnosXpress :: Recuperación de cuenta", templateId: "6a85c7ad96475f2825015277",
message: `Hola ${user.firstName}, Te enviamos un código de verificación para recuperar tu cuenta. Tu código de verificación es: ${code}. Ingresa https://turnosxpress.com.ar/landing/recover-account/verify para establecer una nueva clave`, context: {
username: user.firstName || cleanEmail(data.email),
verificationcode: code,
},
}); });
return; return;
@@ -515,10 +541,16 @@ class UsersManager implements IUsersManager {
throw new Error("El código de verificación es incorrecto"); throw new Error("El código de verificación es incorrecto");
} }
const wasVerificated = Boolean(userTest.verificated);
userTest.verificated = true; userTest.verificated = true;
await userTest.save(); await userTest.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
}
try { try {
const freePlan = await PlansList.plans.findOne({ price: 0 }); const freePlan = await PlansList.plans.findOne({ price: 0 });
if (freePlan) { if (freePlan) {
@@ -724,11 +756,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleUserId; userCheck.external_id = googleUserId;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -770,6 +808,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, sessionUser: newUser.id,
}); });
@@ -821,11 +861,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleData.id; userCheck.external_id = googleData.id;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -867,6 +913,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, sessionUser: newUser.id,
}); });
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { AdminAssistantService } from "./AdminAssistant.Service";
@Route("admin-assistant/collaborator-schedule-details")
@Middlewares(authenticateMiddleware)
export class GetCollaboratorScheduleDetailsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async getCollaboratorScheduleDetails(
@Body() requestBody: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView | ApiValidationError> {
try {
const details = await new AdminAssistantService().getCollaboratorScheduleDetails(requestBody);
this.setStatus(200);
return details;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,16 @@
import { connect } from "mongoose";
import AdminAssistantList from "../../Models/AdminAssistant/AdminAssistant";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
export class AdminAssistantService {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await AdminAssistantList.getCollaboratorScheduleDetails(data);
}
}
@@ -0,0 +1,26 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../Models/Server.Error.model";
import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ArcaActivitiesService, FilterArcaActivitiesParams } from "./ArcaActivities.Service";
@Route("arca-activities/filter-all")
@Middlewares(authenticateMiddleware)
export class ArcaActivitiesFilterAllController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async allArcaActivities(
@Body() requestBody: FilterArcaActivitiesParams
): Promise<TextObjectFilterResult[] | ApiValidationError> {
try {
const activities = await new ArcaActivitiesService().textObjectFilter(requestBody);
this.setStatus(200);
return activities;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,12 @@
import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import { filterArcaActivities } from "../../Models/ArcaActivities/ArcaActivities.Catalog";
export type FilterArcaActivitiesParams = {
name?: string;
};
export class ArcaActivitiesService {
public async textObjectFilter(data: FilterArcaActivitiesParams): Promise<TextObjectFilterResult[]> {
return filterArcaActivities(data.name);
}
}
@@ -0,0 +1,142 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
ArcaCredentialView,
ArcaWsfeDiagnosticsParams,
ArcaWsfeDiagnosticsResult,
ArcaWsfeLastVoucherParams,
ArcaWsfeLastVoucherResult,
ArcaWsaaLoginTestResult,
FindArcaCredentialsParams,
GenerateArcaCredentialCsrParams,
TestArcaWsaaLoginParams,
UploadArcaCredentialCertificateParams,
} from "../../Models/ArcaCredentials/ArcaCredentials.Interface";
import { ArcaCredentialsService } from "./ArcaCredentials.Service";
@Route("arca-credentials/generate-csr")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsGenerateCsrController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async generateCsr(
@Body() requestBody: GenerateArcaCredentialCsrParams
): Promise<ArcaCredentialView | ApiValidationError> {
try {
const credential = await new ArcaCredentialsService().generateCsr(requestBody);
this.setStatus(200);
return credential;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("arca-credentials/upload-certificate")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsUploadCertificateController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async uploadCertificate(
@Body() requestBody: UploadArcaCredentialCertificateParams
): Promise<ArcaCredentialView | ApiValidationError> {
try {
const credential = await new ArcaCredentialsService().uploadCertificate(requestBody);
this.setStatus(200);
return credential;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("arca-credentials/test-wsaa-login")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsTestWsaaLoginController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async testWsaaLogin(
@Body() requestBody: TestArcaWsaaLoginParams
): Promise<ArcaWsaaLoginTestResult | ApiValidationError> {
try {
const result = await new ArcaCredentialsService().testWsaaLogin(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("arca-credentials/wsfe-last-voucher")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsWsfeLastVoucherController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async getWsfeLastVoucher(
@Body() requestBody: ArcaWsfeLastVoucherParams
): Promise<ArcaWsfeLastVoucherResult | ApiValidationError> {
try {
const result = await new ArcaCredentialsService().getWsfeLastVoucher(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("arca-credentials/wsfe-diagnostics")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsWsfeDiagnosticsController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async getWsfeDiagnostics(
@Body() requestBody: ArcaWsfeDiagnosticsParams
): Promise<ArcaWsfeDiagnosticsResult | ApiValidationError> {
try {
const result = await new ArcaCredentialsService().getWsfeDiagnostics(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("arca-credentials/find")
@Middlewares(authenticateMiddleware)
export class ArcaCredentialsFindController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(
@Body() requestBody: FindArcaCredentialsParams
): Promise<ArcaCredentialView[] | ApiValidationError> {
try {
const credentials = await new ArcaCredentialsService().find(requestBody);
this.setStatus(200);
return credentials;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,46 @@
import { connect } from "mongoose";
import ArcaCredentials from "../../Models/ArcaCredentials/ArcaCredentials";
import {
ArcaCredentialView,
ArcaWsfeDiagnosticsParams,
ArcaWsfeDiagnosticsResult,
ArcaWsfeLastVoucherParams,
ArcaWsfeLastVoucherResult,
ArcaWsaaLoginTestResult,
FindArcaCredentialsParams,
GenerateArcaCredentialCsrParams,
TestArcaWsaaLoginParams,
UploadArcaCredentialCertificateParams,
} from "../../Models/ArcaCredentials/ArcaCredentials.Interface";
export class ArcaCredentialsService {
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.generateCsr(data);
}
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.uploadCertificate(data);
}
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.testWsaaLogin(data);
}
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.getWsfeLastVoucher(data);
}
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.getWsfeDiagnostics(data);
}
public async find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.find(data);
}
}
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async toggleFixedCompanyPost( public async toggleFixedCompanyPost(
@Body() requestBody: FixCompanyPostParams @Body() requestBody: FixCompanyPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().toggleFixedPost(requestBody); await new CompaniesService().toggleFixedPost(requestBody);
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async moveFixedCompanyPost( public async moveFixedCompanyPost(
@Body() requestBody: MoveCompanyFixedPostParams @Body() requestBody: MoveCompanyFixedPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().moveFixedPost(requestBody); await new CompaniesService().moveFixedPost(requestBody);
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setNotificationAuto( public async setNotificationAuto(
@Body() requestBody: SetNotificationAutoParams @Body() requestBody: SetNotificationAutoParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setNotificationAuto(requestBody); await new CompaniesService().setNotificationAuto(requestBody);
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setPublishedStatus( public async setPublishedStatus(
@Body() requestBody: SetOrganizationPublishedStatusParams @Body() requestBody: SetOrganizationPublishedStatusParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setPublishedStatus(requestBody); await new CompaniesService().setPublishedStatus(requestBody);
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue creada con exito") @SuccessResponse(200, "La compañia fue creada con exito")
@Post() @Post()
public async createCompany( public async createCompany(
@Body() requestBody: CreateCompanyParams @Body() requestBody: CreateCompanyParams,
): Promise<ICompany | ApiValidationError> { ): Promise<ICompany | ApiValidationError> {
try { try {
const company = await new CompaniesService().createCompany(requestBody); const company = await new CompaniesService().createCompany(requestBody);
@@ -155,7 +155,7 @@ export class DeleteCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async deleteCompany( public async deleteCompany(
@Body() requestBody: DeleteCompanyParams @Body() requestBody: DeleteCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompany(requestBody); await new CompaniesService().deleteCompany(requestBody);
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async updateCompany( public async updateCompany(
@Body() requestBody: UpdateCompanyParams @Body() requestBody: UpdateCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().updateCompany(requestBody); await new CompaniesService().updateCompany(requestBody);
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getById( public async getById(
@Body() requestBody: OranizationsViewByIdParams @Body() requestBody: OranizationsViewByIdParams,
): Promise<MyOranizationsView | ApiValidationError> { ): Promise<MyOranizationsView | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getById(requestBody); const companies = await new CompaniesService().getById(requestBody);
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getByUserId( public async getByUserId(
@Body() requestBody: MyOranizationsViewParams @Body() requestBody: MyOranizationsViewParams,
): Promise<MyOranizationsView[] | ApiValidationError> { ): Promise<MyOranizationsView[] | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getByUserId(requestBody); const companies = await new CompaniesService().getByUserId(requestBody);
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async paginateUsers( public async paginateUsers(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<PaginateCompaniesResults | ApiValidationError> { ): Promise<PaginateCompaniesResults | ApiValidationError> {
try { try {
const users = await new CompaniesService().paginateCompanies(requestBody); const users = await new CompaniesService().paginateCompanies(requestBody);
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
public async uploadHeader( public async uploadHeader(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyHeader({ await new CompaniesService().setCompanyHeader({
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
public async uploadIcon( public async uploadIcon(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyIcon({ await new CompaniesService().setCompanyIcon({
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteHeader( public async deleteHeader(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyHeader(data); await new CompaniesService().deleteCompanyHeader(data);
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteIcon( public async deleteIcon(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyIcon(data); await new CompaniesService().deleteCompanyIcon(data);
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async filterAllOrganizations( public async filterAllOrganizations(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<TextObjectFilterResult[] | ApiValidationError> { ): Promise<TextObjectFilterResult[] | ApiValidationError> {
try { try {
const clients = await new CompaniesService().filterAll(requestBody); const clients = await new CompaniesService().filterAll(requestBody);
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async checkName( public async checkName(
@Body() requestBody: { name: string } @Body() requestBody: { name: string },
): Promise<{ valid: boolean } | ApiValidationError> { ): Promise<{ valid: boolean } | ApiValidationError> {
try { try {
const valid = await new CompaniesService().checkNameExists(requestBody.name); const valid = await new CompaniesService().checkNameExists(requestBody.name);
@@ -405,3 +405,24 @@ export class CheckCompanyNameController extends Controller {
} }
} }
} }
@Route("companies/check-admin")
@Middlewares(authenticateMiddleware)
export class CheckCompanyAdminController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async checkAdmin(
@Body() requestBody: { companyId: string; sessionUser: string },
): Promise<boolean | ApiValidationError> {
try {
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
this.setStatus(200);
return isAdmin;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -112,4 +112,9 @@ export class CompaniesService {
const isValid = await CompaniestList.isValidName({ name }); const isValid = await CompaniestList.isValidName({ name });
return isValid; return isValid;
} }
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await CompaniestList.checkCompanyAdmin(data);
}
} }
@@ -0,0 +1,49 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
CreateInvoiceFromCashMovementParams,
FindInvoicesParams,
IInvoice,
} from "../../Models/Invoices/Invoices.Interface";
import { InvoicesService } from "./Invoices.Service";
@Route("invoices/create-from-cash-movement")
@Middlewares(authenticateMiddleware)
export class InvoicesCreateFromCashMovementController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async createFromCashMovement(
@Body() requestBody: CreateInvoiceFromCashMovementParams
): Promise<IInvoice | ApiValidationError> {
try {
const invoice = await new InvoicesService().createFromCashMovement(requestBody);
this.setStatus(200);
return invoice;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("invoices/find")
@Middlewares(authenticateMiddleware)
export class InvoicesFindController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(@Body() requestBody: FindInvoicesParams): Promise<IInvoice[] | ApiValidationError> {
try {
const invoices = await new InvoicesService().find(requestBody);
this.setStatus(200);
return invoices;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,19 @@
import { connect } from "mongoose";
import Invoices from "../../Models/Invoices/Invoices";
import {
CreateInvoiceFromCashMovementParams,
FindInvoicesParams,
IInvoice,
} from "../../Models/Invoices/Invoices.Interface";
export class InvoicesService {
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await Invoices.createFromCashMovement(data);
}
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await Invoices.find(data);
}
}
@@ -6,6 +6,9 @@ import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import PlansList from "../../Models/Plans/Plans";
import UserList from "../../Models/Users/Users";
import { NotificationsManager } from "../../Models/Notifications/Notifications";
type MercadoPagoPaymentData = { type MercadoPagoPaymentData = {
id: string | number; id: string | number;
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"]; const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService { export class MercadoPagoWebhookService {
private formatTemplateDate(date: Date): string {
return new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
private formatTemplateAmount(amount: number): string {
return new Intl.NumberFormat("es-AR", {
style: "currency",
currency: "ARS",
}).format(amount);
}
private async sendApprovedPaidPlanEmail(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
approvedAt: Date,
periodStart: Date
): Promise<void> {
try {
const plan = await PlansList.findOne({ _id: String(subscription.planId) });
if (plan.price <= 0) {
return;
}
const user = await UserList.users.findOne({ _id: userId });
if (!user) {
return;
}
await NotificationsManager.sendEmail({
email: user.email,
templateId: "6a85ebb1323720a53d0cfd3f",
context: {
username: user.firstName || user.email,
nombre_plan: plan.name,
importe: this.formatTemplateAmount(paymentData.transaction_amount || 0),
fecha_pago: this.formatTemplateDate(approvedAt),
periodo_desde: this.formatTemplateDate(periodStart),
periodo_hasta: this.formatTemplateDate(subscription.endDate),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de pago de plan aprobado:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de pago de plan aprobado:", e);
}
}
public async handleWebhook(body: any): Promise<void> { public async handleWebhook(body: any): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt) const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate ? subscription.endDate
: approvedAt; : approvedAt;
const transactionId = String(paymentData.id);
const existingPayments = await PlanPaymentsList.find({ transactionId });
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
if (isUpgradePayment && subscription.pendingPaymentPlanId) { if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId; subscription.planId = subscription.pendingPaymentPlanId;
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
paymentDate: approvedAt, paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed", status: "completed",
transactionId: String(paymentData.id), transactionId,
}); });
if (shouldSendApprovedPaymentEmail) {
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
}
} }
private async applyRejectedPayment( private async applyRejectedPayment(
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
interface SendEmailByContentParams { interface SendEmailByContentParams {
systemToken: string; systemToken: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
} }
interface SendWapByContentParams { interface SendWapByContentParams {
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
email: requestBody.email, email: requestBody.email,
subject: requestBody.subject, subject: requestBody.subject,
message: requestBody.message, message: requestBody.message,
templateId: requestBody.templateId,
context: requestBody.context,
}); });
this.setStatus(200); this.setStatus(200);
@@ -0,0 +1,51 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
UpsertOrganizationFiscalProfileParams,
} from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
import { OrganizationFiscalProfilesService } from "./OrganizationFiscalProfiles.Service";
@Route("organization-fiscal-profiles/upsert")
@Middlewares(authenticateMiddleware)
export class OrganizationFiscalProfilesUpsertController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async upsert(
@Body() requestBody: UpsertOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile | ApiValidationError> {
try {
const profile = await new OrganizationFiscalProfilesService().upsert(requestBody);
this.setStatus(200);
return profile;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("organization-fiscal-profiles/find")
@Middlewares(authenticateMiddleware)
export class OrganizationFiscalProfilesFindController extends Controller {
@SuccessResponse(200, "Done")
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(
@Body() requestBody: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[] | ApiValidationError> {
try {
const profiles = await new OrganizationFiscalProfilesService().find(requestBody);
this.setStatus(200);
return profiles;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,23 @@
import { connect } from "mongoose";
import OrganizationFiscalProfiles from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles";
import {
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
UpsertOrganizationFiscalProfileParams,
} from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
export class OrganizationFiscalProfilesService {
public async upsert(
data: UpsertOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await OrganizationFiscalProfiles.upsert(data);
}
public async find(
data: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await OrganizationFiscalProfiles.find(data);
}
}
@@ -10,6 +10,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
FindSchedulesParams, FindSchedulesParams,
CollaboratorSchedulesView, CollaboratorSchedulesView,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
} }
@Route("schedules/get-all-by-collaborator") @Route("schedules/get-all-by-collaborator")
@Middlewares(authenticateMiddleware)
export class GetAllSchedulsByCollaboratoreController extends Controller { export class GetAllSchedulsByCollaboratoreController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El empleado fue creado con exito") @SuccessResponse(200, "El empleado fue creado con exito")
@@ -81,6 +84,27 @@ export class UpdateScheduleController extends Controller {
} }
} }
@Route("schedules/apply")
@Middlewares(authenticateMiddleware)
export class ApplyScheduleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async applyScheduleChanges(
@Body() requestBody: ScheduleApplyParams
): Promise<ScheduleApplyResult | ApiValidationError> {
try {
const result = await new SchedulesService().applyScheduleChanges(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("schedules/delete") @Route("schedules/delete")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
export class DeleteScheduleController extends Controller { export class DeleteScheduleController extends Controller {
@@ -9,6 +9,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
CollaboratorSchedulesView, CollaboratorSchedulesView,
FindSchedulesParams, FindSchedulesParams,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
export class SchedulesService { export class SchedulesService {
@@ -26,6 +28,12 @@ export class SchedulesService {
await ScheduleList.updateSchedule(data); await ScheduleList.updateSchedule(data);
} }
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ScheduleList.applyScheduleChanges(data);
}
public async deleteSchedule(data: DeleteScheduleParams): Promise<void> { public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await ScheduleList.delete(data); await ScheduleList.delete(data);
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+33
View File
@@ -0,0 +1,33 @@
# Gradle
.gradle/
build/
**/build/
gradle-app.setting
!gradle-wrapper.jar
# Android Studio
.idea/
*.iml
local.properties
.navigation/
.externalNativeBuild/
.cxx/
# Signing
keystore.properties
*.jks
*.keystore
*.p12
*.pem
# Generated
captures/
outputs/
*.apk
*.aab
*.ap_
# Logs / OS
*.log
Thumbs.db
.DS_Store
+85
View File
@@ -0,0 +1,85 @@
# TurnosXpress Android TWA
Minimal Android Trusted Web Activity app for `https://turnosxpress.com.ar`.
## Project
- Package/application id: `com.hdrdevs.turnosxpress`
- Launch URL: `https://turnosxpress.com.ar`
- TWA library: `com.google.androidbrowserhelper:androidbrowserhelper:2.7.2`
- Launcher activity: `com.google.androidbrowserhelper.trusted.LauncherActivity`
## Build
This folder intentionally does not include a Gradle wrapper. Open `tx-android` in Android Studio, or use an installed Gradle distribution:
```powershell
cd tx-android
gradle tasks
gradle assembleDebug
```
If Android Studio creates `local.properties`, keep it local. It is ignored by Git.
## Signing
For local debug builds Android uses the debug keystore. For Play Store releases, sign with the release keystore or Play App Signing certificate configured for this package.
Example local release keystore creation:
```powershell
keytool -genkeypair -v -keystore turnosxpress-release.jks -alias turnosxpress -keyalg RSA -keysize 2048 -validity 10000
```
Do not commit keystores or signing passwords. Put local signing values in `keystore.properties` if you later wire release signing into Gradle.
## SHA-256 Fingerprint
Digital Asset Links must use the SHA-256 fingerprint of the certificate that signs the installed APK.
Debug keystore example:
```powershell
keytool -list -v -alias androiddebugkey -keystore "$env:USERPROFILE\.android\debug.keystore" -storepass android -keypass android
```
Release keystore example:
```powershell
keytool -list -v -alias turnosxpress -keystore .\turnosxpress-release.jks
```
If Play App Signing is enabled, use the SHA-256 fingerprint from Play Console's app signing certificate, not only the upload key.
## Digital Asset Links
Publish this file publicly at:
```text
https://turnosxpress.com.ar/.well-known/assetlinks.json
```
Example:
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.hdrdevs.turnosxpress",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
```
The JSON must be served over HTTPS with `Content-Type: application/json` and no redirects that break access to `/.well-known/assetlinks.json`.
## Play Protect And Local APK Notes
Local APK installs can show Play Protect warnings because the APK is not installed from Google Play and may be signed by a new or unknown certificate. This is expected during sideload testing and does not prove the TWA or Digital Asset Links setup is wrong.
The full-screen trusted experience only works when Android can verify the website-to-app relationship through Digital Asset Links for the certificate used to sign that APK. Until that is valid, Chrome may open the site with browser UI or behave like a Custom Tab.
+21
View File
@@ -0,0 +1,21 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.hdrdevs.turnosxpress'
compileSdk 36
defaultConfig {
applicationId 'com.hdrdevs.turnosxpress'
minSdk 23
targetSdk 35
versionCode 1
versionName '1.0.0'
}
}
dependencies {
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.7.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
}
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.hdrdevs.turnosxpress",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 23
}
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:exported="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="https://turnosxpress.com.ar" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/twa_status_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
android:resource="@color/twa_status_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/twa_navigation_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
android:resource="@color/twa_navigation_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
android:value="customtabs" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="turnosxpress.com.ar"
android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:exported="false"
android:theme="@style/Theme.TurnosXpressTwa" />
</application>
</manifest>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/launcher_background"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M24,24h60v12h-24v48h-12v-48h-24z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M30,72h48v12h-48z" />
</vector>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Some files were not shown because too many files have changed in this diff Show More