diff --git a/.env.example b/.env.example index 6f0b109..4426eba 100644 --- a/.env.example +++ b/.env.example @@ -45,5 +45,10 @@ MONNIFY_CONTRACT_CODE=your-monnify-contract-code FLUTTERWAVE_SECRET_KEY=your-flutterwave-secret-key FLUTTERWAVE_BASE_URL=https://api.flutterwave.com +# Bank connection webhooks (Paystack / Stripe) +PAYSTACK_WEBHOOK_SECRET=your-paystack-webhook-secret +PAYSTACK_SECRET_KEY=your-paystack-secret-key +STRIPE_WEBHOOK_SECRET=your-stripe-webhook-signing-secret + # CORS CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.vestroll.com \ No newline at end of file diff --git a/drizzle/migrations/0002_add_bank_accounts.sql b/drizzle/migrations/0002_add_bank_accounts.sql new file mode 100644 index 0000000..0a45ff6 --- /dev/null +++ b/drizzle/migrations/0002_add_bank_accounts.sql @@ -0,0 +1,22 @@ +CREATE TYPE "public"."bank_account_status" AS ENUM('pending', 'verified', 'disconnected');--> statement-breakpoint +CREATE TYPE "public"."bank_connection_provider" AS ENUM('paystack', 'stripe');--> statement-breakpoint +CREATE TABLE "bank_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "provider" "bank_connection_provider" NOT NULL, + "provider_account_id" varchar(255) NOT NULL, + "account_number" varchar(255), + "bank_name" varchar(255), + "account_holder_name" varchar(255), + "status" "bank_account_status" DEFAULT 'pending' NOT NULL, + "verified_at" timestamp, + "disconnected_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "bank_accounts" ADD CONSTRAINT "bank_accounts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "bank_accounts_organization_id_idx" ON "bank_accounts" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "bank_accounts_provider_account_id_idx" ON "bank_accounts" USING btree ("provider_account_id");--> statement-breakpoint +CREATE INDEX "bank_accounts_status_idx" ON "bank_accounts" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "bank_accounts_provider_account_id_unique" ON "bank_accounts" USING btree ("provider_account_id"); diff --git a/drizzle/migrations/meta/0002_snapshot.json b/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..8663012 --- /dev/null +++ b/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,3547 @@ +{ + "id": "ed6f815e-a24a-431b-9488-e856ed0bacfa", + "prevId": "2632122e-f98a-46a4-88c2-983cc7e2813a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "audit_event", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_codes": { + "name": "backup_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_codes_user_id_users_id_fk": { + "name": "backup_codes_user_id_users_id_fk", + "tableFrom": "backup_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.biometric_logs": { + "name": "biometric_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "last_login_ua": { + "name": "last_login_ua", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "biometric_logs_user_id_users_id_fk": { + "name": "biometric_logs_user_id_users_id_fk", + "tableFrom": "biometric_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_profiles": { + "name": "company_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "brand_name": { + "name": "brand_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registered_name": { + "name": "registered_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registration_number": { + "name": "registration_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "vat_number": { + "name": "vat_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "alt_address": { + "name": "alt_address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "billing_address": { + "name": "billing_address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "billing_alt_address": { + "name": "billing_alt_address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "billing_city": { + "name": "billing_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_region": { + "name": "billing_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_country": { + "name": "billing_country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "billing_postal_code": { + "name": "billing_postal_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_profiles_user_id_users_id_fk": { + "name": "company_profiles_user_id_users_id_fk", + "tableFrom": "company_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_profiles_organization_id_organizations_id_fk": { + "name": "company_profiles_organization_id_organizations_id_fk", + "tableFrom": "company_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "company_profiles_organization_id_unique": { + "name": "company_profiles_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contracts": { + "name": "contracts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "employee_id": { + "name": "employee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payment_type": { + "name": "payment_type", + "type": "payment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "contract_type": { + "name": "contract_type", + "type": "contract_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contract_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_signature'" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contracts_organization_id_idx": { + "name": "contracts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_status_idx": { + "name": "contracts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contracts_organization_id_organizations_id_fk": { + "name": "contracts_organization_id_organizations_id_fk", + "tableFrom": "contracts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contracts_employee_id_employees_id_fk": { + "name": "contracts_employee_id_employees_id_fk", + "tableFrom": "contracts", + "tableTo": "employees", + "columnsFrom": [ + "employee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "otp_hash": { + "name": "otp_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.employees": { + "name": "employees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "department": { + "name": "department", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "employee_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "employee_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'Active'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bank_name": { + "name": "bank_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "routing_number": { + "name": "routing_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sort_code": { + "name": "sort_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "iban": { + "name": "iban", + "type": "varchar(34)", + "primaryKey": false, + "notNull": false + }, + "swift_code": { + "name": "swift_code", + "type": "varchar(11)", + "primaryKey": false, + "notNull": false + }, + "account_type": { + "name": "account_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "account_holder_name": { + "name": "account_holder_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_account_verified": { + "name": "is_account_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "account_verified_at": { + "name": "account_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "bank_address": { + "name": "bank_address", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "bank_city": { + "name": "bank_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bank_country": { + "name": "bank_country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "employees_organization_id_idx": { + "name": "employees_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "employees_account_number_idx": { + "name": "employees_account_number_idx", + "columns": [ + { + "expression": "account_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "employees_routing_number_idx": { + "name": "employees_routing_number_idx", + "columns": [ + { + "expression": "routing_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "employees_organization_id_organizations_id_fk": { + "name": "employees_organization_id_organizations_id_fk", + "tableFrom": "employees", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "employees_user_id_users_id_fk": { + "name": "employees_user_id_users_id_fk", + "tableFrom": "employees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fiat_transactions": { + "name": "fiat_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "fiat_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "fiat_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_reference": { + "name": "provider_reference", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fiat_transactions_organization_id_idx": { + "name": "fiat_transactions_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fiat_transactions_status_idx": { + "name": "fiat_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fiat_transactions_type_idx": { + "name": "fiat_transactions_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fiat_transactions_organization_id_organizations_id_fk": { + "name": "fiat_transactions_organization_id_organizations_id_fk", + "tableFrom": "fiat_transactions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fiat_transactions_provider_reference_unique": { + "name": "fiat_transactions_provider_reference_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_reference" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "employee_id": { + "name": "employee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_no": { + "name": "invoice_no", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paid_in": { + "name": "paid_in", + "type": "payment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "issue_date": { + "name": "issue_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_organization_id_idx": { + "name": "invoices_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_status_idx": { + "name": "invoices_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_organization_id_organizations_id_fk": { + "name": "invoices_organization_id_organizations_id_fk", + "tableFrom": "invoices", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_employee_id_employees_id_fk": { + "name": "invoices_employee_id_employees_id_fk", + "tableFrom": "invoices", + "tableTo": "employees", + "columnsFrom": [ + "employee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_contract_id_contracts_id_fk": { + "name": "invoices_contract_id_contracts_id_fk", + "tableFrom": "invoices", + "tableTo": "contracts", + "columnsFrom": [ + "contract_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kyb_audit_logs": { + "name": "kyb_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kyb_audit_logs_entity_id_idx": { + "name": "kyb_audit_logs_entity_id_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kyb_audit_logs_actor_id_idx": { + "name": "kyb_audit_logs_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kyb_audit_logs_actor_id_users_id_fk": { + "name": "kyb_audit_logs_actor_id_users_id_fk", + "tableFrom": "kyb_audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kyb_verifications": { + "name": "kyb_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "registration_type": { + "name": "registration_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registration_no": { + "name": "registration_no", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "incorporation_certificate_path": { + "name": "incorporation_certificate_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "incorporation_certificate_url": { + "name": "incorporation_certificate_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "memorandum_article_path": { + "name": "memorandum_article_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "memorandum_article_url": { + "name": "memorandum_article_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "form_c02_c07_path": { + "name": "form_c02_c07_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "form_c02_c07_url": { + "name": "form_c02_c07_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "kyb_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_code": { + "name": "rejection_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kyb_verifications_user_id_users_id_fk": { + "name": "kyb_verifications_user_id_users_id_fk", + "tableFrom": "kyb_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kyb_verifications_user_id_unique": { + "name": "kyb_verifications_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_attempts": { + "name": "login_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "last_login_ua": { + "name": "last_login_ua", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "login_attempts_created_at_idx": { + "name": "login_attempts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.milestones": { + "name": "milestones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "milestone_name": { + "name": "milestone_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "milestone_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "employee_id": { + "name": "employee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "milestones_employee_id_idx": { + "name": "milestones_employee_id_idx", + "columns": [ + { + "expression": "employee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "milestones_employee_id_employees_id_fk": { + "name": "milestones_employee_id_employees_id_fk", + "tableFrom": "milestones", + "tableTo": "employees", + "columnsFrom": [ + "employee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_fiat_balances": { + "name": "organization_fiat_balances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'NGN'" + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_fiat_balances_organization_id_organizations_id_fk": { + "name": "organization_fiat_balances_organization_id_organizations_id_fk", + "tableFrom": "organization_fiat_balances", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_fiat_balances_organization_id_unique": { + "name": "organization_fiat_balances_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "invitation_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_invitations_organization_id_idx": { + "name": "organization_invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_invitations_email_idx": { + "name": "organization_invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_invitations_token_idx": { + "name": "organization_invitations_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_invitations_status_idx": { + "name": "organization_invitations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invitations_organization_id_organizations_id_fk": { + "name": "organization_invitations_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invitations_invited_by_user_id_users_id_fk": { + "name": "organization_invitations_invited_by_user_id_users_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_invitations_token_unique": { + "name": "organization_invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_wallets": { + "name": "organization_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wallet_address": { + "name": "wallet_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "virtual_account_number": { + "name": "virtual_account_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "virtual_bank_name": { + "name": "virtual_bank_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "funded": { + "name": "funded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "funded_at": { + "name": "funded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_wallets_organization_id_organizations_id_fk": { + "name": "organization_wallets_organization_id_organizations_id_fk", + "tableFrom": "organization_wallets", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_wallets_organization_id_unique": { + "name": "organization_wallets_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_preference": { + "name": "provider_preference", + "type": "fiat_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'monnify'" + }, + "industry": { + "name": "industry", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registration_number": { + "name": "registration_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registered_street": { + "name": "registered_street", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registered_city": { + "name": "registered_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registered_state": { + "name": "registered_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registered_postal_code": { + "name": "registered_postal_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "registered_country": { + "name": "registered_country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_street": { + "name": "billing_street", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_city": { + "name": "billing_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_state": { + "name": "billing_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_postal_code": { + "name": "billing_postal_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_country": { + "name": "billing_country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_registration_challenges": { + "name": "passkey_registration_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "challenge_hash": { + "name": "challenge_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_registration_challenges_user_id_idx": { + "name": "passkey_registration_challenges_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_registration_challenges_user_id_users_id_fk": { + "name": "passkey_registration_challenges_user_id_users_id_fk", + "tableFrom": "passkey_registration_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_registration_challenges_challenge_hash_unique": { + "name": "passkey_registration_challenges_challenge_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "challenge_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_resets_user_id_idx": { + "name": "password_resets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_resets_token_hash_unique": { + "name": "password_resets_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signer_audits": { + "name": "signer_audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "signer_public_key": { + "name": "signer_public_key", + "type": "varchar(56)", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signer_audits_transaction_hash_idx": { + "name": "signer_audits_transaction_hash_idx", + "columns": [ + { + "expression": "transaction_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_off_requests": { + "name": "time_off_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "employee_id": { + "name": "employee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "time_off_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_duration": { + "name": "total_duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_off_requests_organization_id_idx": { + "name": "time_off_requests_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_off_requests_status_idx": { + "name": "time_off_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_off_requests_organization_id_organizations_id_fk": { + "name": "time_off_requests_organization_id_organizations_id_fk", + "tableFrom": "time_off_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_off_requests_employee_id_employees_id_fk": { + "name": "time_off_requests_employee_id_employees_id_fk", + "tableFrom": "time_off_requests", + "tableTo": "employees", + "columnsFrom": [ + "employee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timesheets": { + "name": "timesheets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "employee_id": { + "name": "employee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_worked": { + "name": "total_worked", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "timesheets_organization_id_idx": { + "name": "timesheets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timesheets_employee_id_idx": { + "name": "timesheets_employee_id_idx", + "columns": [ + { + "expression": "employee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timesheets_status_idx": { + "name": "timesheets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "timesheets_organization_id_organizations_id_fk": { + "name": "timesheets_organization_id_organizations_id_fk", + "tableFrom": "timesheets", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timesheets_employee_id_employees_id_fk": { + "name": "timesheets_employee_id_employees_id_fk", + "tableFrom": "timesheets", + "tableTo": "employees", + "columnsFrom": [ + "employee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction_cache": { + "name": "transaction_cache", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_token": { + "name": "device_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trusted_devices_device_token_unique": { + "name": "trusted_devices_device_token_unique", + "nullsNotDistinct": false, + "columns": [ + "device_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor_attempts": { + "name": "two_factor_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "two_factor_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_attempts_user_id_users_id_fk": { + "name": "two_factor_attempts_user_id_users_id_fk", + "tableFrom": "two_factor_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "organization_name": { + "name": "organization_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_verification'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled_at": { + "name": "two_factor_enabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_two_factor_attempts": { + "name": "failed_two_factor_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "two_factor_lockout_until": { + "name": "two_factor_lockout_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "account_locked_reason": { + "name": "account_locked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "oauth_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "signer_type": { + "name": "signer_type", + "type": "signer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'Email'" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "last_login_ua": { + "name": "last_login_ua", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bank_accounts": { + "name": "bank_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "bank_connection_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_number": { + "name": "account_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bank_name": { + "name": "bank_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "account_holder_name": { + "name": "account_holder_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "bank_account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bank_accounts_organization_id_idx": { + "name": "bank_accounts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bank_accounts_provider_account_id_idx": { + "name": "bank_accounts_provider_account_id_idx", + "columns": [ + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bank_accounts_status_idx": { + "name": "bank_accounts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bank_accounts_organization_id_organizations_id_fk": { + "name": "bank_accounts_organization_id_organizations_id_fk", + "tableFrom": "bank_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bank_accounts_provider_account_id_unique": { + "name": "bank_accounts_provider_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.approval_status": { + "name": "approval_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.audit_event": { + "name": "audit_event", + "schema": "public", + "values": [ + "ROLE_CHANGE", + "EMAIL_CHANGE", + "BIOMETRIC_ENROLLMENT", + "PASSWORD_CHANGE", + "ACCOUNT_DELETION", + "SECURITY_CHANGE" + ] + }, + "public.contract_status": { + "name": "contract_status", + "schema": "public", + "values": [ + "pending_signature", + "in_review", + "rejected", + "active", + "completed" + ] + }, + "public.contract_type": { + "name": "contract_type", + "schema": "public", + "values": [ + "fixed_rate", + "pay_as_you_go", + "milestone" + ] + }, + "public.employee_status": { + "name": "employee_status", + "schema": "public", + "values": [ + "Active", + "Inactive" + ] + }, + "public.employee_type": { + "name": "employee_type", + "schema": "public", + "values": [ + "Freelancer", + "Contractor" + ] + }, + "public.fiat_provider": { + "name": "fiat_provider", + "schema": "public", + "values": [ + "monnify", + "flutterwave" + ] + }, + "public.fiat_transaction_status": { + "name": "fiat_transaction_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed" + ] + }, + "public.fiat_transaction_type": { + "name": "fiat_transaction_type", + "schema": "public", + "values": [ + "deposit", + "withdrawal", + "payout" + ] + }, + "public.invitation_role": { + "name": "invitation_role", + "schema": "public", + "values": [ + "admin", + "hr_manager", + "payroll_manager", + "employee" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "pending", + "approved", + "unpaid", + "overdue", + "paid", + "rejected" + ] + }, + "public.kyb_status": { + "name": "kyb_status", + "schema": "public", + "values": [ + "not_started", + "pending", + "verified", + "rejected" + ] + }, + "public.leave_status": { + "name": "leave_status", + "schema": "public", + "values": [ + "Pending", + "Approved", + "Rejected", + "Cancelled" + ] + }, + "public.leave_type": { + "name": "leave_type", + "schema": "public", + "values": [ + "vacation", + "sick", + "personal", + "other" + ] + }, + "public.milestone_status": { + "name": "milestone_status", + "schema": "public", + "values": [ + "pending", + "in_progress", + "completed", + "approved", + "rejected" + ] + }, + "public.oauth_provider": { + "name": "oauth_provider", + "schema": "public", + "values": [ + "google", + "apple" + ] + }, + "public.payment_type": { + "name": "payment_type", + "schema": "public", + "values": [ + "crypto", + "fiat" + ] + }, + "public.signer_type": { + "name": "signer_type", + "schema": "public", + "values": [ + "Email", + "Passkey" + ] + }, + "public.time_off_type": { + "name": "time_off_type", + "schema": "public", + "values": [ + "paid", + "unpaid" + ] + }, + "public.two_factor_method": { + "name": "two_factor_method", + "schema": "public", + "values": [ + "totp", + "backup_code" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "pending_verification", + "active", + "suspended" + ] + }, + "public.bank_account_status": { + "name": "bank_account_status", + "schema": "public", + "values": [ + "pending", + "verified", + "disconnected" + ] + }, + "public.bank_connection_provider": { + "name": "bank_connection_provider", + "schema": "public", + "values": [ + "paystack", + "stripe" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 159e87b..cac65af 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777132800000, "tag": "0001_add_payroll_drafts", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1780314990039, + "tag": "0002_add_bank_accounts", + "breakpoints": true } ] } diff --git a/src/app/api/v1/finance/webhooks/bank/route.test.ts b/src/app/api/v1/finance/webhooks/bank/route.test.ts new file mode 100644 index 0000000..acd46ee --- /dev/null +++ b/src/app/api/v1/finance/webhooks/bank/route.test.ts @@ -0,0 +1,296 @@ +import crypto from "crypto"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; +import { + BankAccountStatus, + BankWebhookProvider, +} from "@/server/enums/bank-account.enum"; +import type { BankWebhookProcessResult } from "@/server/services/bank-webhook.service"; + +vi.mock("@/server/services/bank-webhook.service", () => ({ + BankWebhookService: { + processWebhook: vi.fn(), + }, +})); + +import { BankWebhookService } from "@/server/services/bank-webhook.service"; + +function makeRequest( + body: string, + headers: Record = {}, + query = "", +) { + return new NextRequest( + `http://localhost/api/v1/finance/webhooks/bank${query}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body, + }, + ); +} + +describe("POST /api/v1/finance/webhooks/bank", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("processes a Paystack webhook with valid signature header", async () => { + vi.mocked(BankWebhookService.processWebhook).mockResolvedValue({ + received: true, + statusUpdated: true, + eventType: "customeridentification.success", + providerAccountId: "CUS_abc123", + status: BankAccountStatus.VERIFIED, + } satisfies BankWebhookProcessResult); + + const body = JSON.stringify({ + event: "customeridentification.success", + data: { customer_code: "CUS_abc123" }, + }); + + const res = await POST( + makeRequest(body, { "x-paystack-signature": "valid-signature" }), + ); + + expect(res.status).toBe(200); + expect(BankWebhookService.processWebhook).toHaveBeenCalledWith( + BankWebhookProvider.PAYSTACK, + body, + "valid-signature", + ); + + const json = await res.json(); + expect(json.success).toBe(true); + expect(json.data.statusUpdated).toBe(true); + }); + + it("processes a Stripe webhook with valid signature header", async () => { + vi.mocked(BankWebhookService.processWebhook).mockResolvedValue({ + received: true, + statusUpdated: true, + eventType: "financial_connections.account.created", + providerAccountId: "fca_abc123", + status: BankAccountStatus.VERIFIED, + } satisfies BankWebhookProcessResult); + + const body = JSON.stringify({ + type: "financial_connections.account.created", + data: { object: { id: "fca_abc123" } }, + }); + + const res = await POST( + makeRequest(body, { "stripe-signature": "t=123,v1=abc" }), + ); + + expect(res.status).toBe(200); + expect(BankWebhookService.processWebhook).toHaveBeenCalledWith( + BankWebhookProvider.STRIPE, + body, + "t=123,v1=abc", + ); + }); + + it("resolves provider from query param when signature header is absent", async () => { + vi.mocked(BankWebhookService.processWebhook).mockResolvedValue({ + received: true, + statusUpdated: false, + eventType: null, + providerAccountId: null, + status: null, + }); + + const body = JSON.stringify({ event: "unknown.event", data: {} }); + + const res = await POST( + makeRequest(body, { "x-paystack-signature": "sig" }, "?provider=paystack"), + ); + + expect(res.status).toBe(200); + expect(BankWebhookService.processWebhook).toHaveBeenCalledWith( + BankWebhookProvider.PAYSTACK, + body, + "sig", + ); + }); + + it("returns 400 when provider cannot be determined", async () => { + const res = await POST(makeRequest(JSON.stringify({ event: "test" }))); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + expect(json.message).toContain("Unable to determine webhook provider"); + }); + + it("returns 401 when signature header is missing", async () => { + const res = await POST( + makeRequest(JSON.stringify({ event: "test" }), {}, "?provider=paystack"), + ); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.message).toContain("Missing webhook signature"); + }); + + it("returns 401 when signature verification fails", async () => { + const { UnauthorizedError } = await import("@/server/utils/errors"); + vi.mocked(BankWebhookService.processWebhook).mockRejectedValue( + new UnauthorizedError("Invalid Paystack webhook signature"), + ); + + const res = await POST( + makeRequest(JSON.stringify({ event: "test" }), { + "x-paystack-signature": "invalid", + }), + ); + + expect(res.status).toBe(401); + }); + + it("returns 500 for unexpected errors", async () => { + vi.mocked(BankWebhookService.processWebhook).mockRejectedValue( + new Error("Unexpected failure"), + ); + + const res = await POST( + makeRequest(JSON.stringify({ event: "test" }), { + "x-paystack-signature": "sig", + }), + ); + + expect(res.status).toBe(500); + }); +}); + +describe("BankWebhookSignatureUtils", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("verifies Paystack signatures using the webhook secret", async () => { + const { BankWebhookSignatureUtils } = await import( + "@/server/utils/bank-webhook-signature.utils" + ); + + const secret = "paystack-test-secret"; + process.env.PAYSTACK_WEBHOOK_SECRET = secret; + + const rawBody = JSON.stringify({ + event: "customeridentification.success", + data: { customer_code: "CUS_abc123" }, + }); + + const signature = crypto + .createHmac("sha512", secret) + .update(rawBody) + .digest("hex"); + + expect(() => + BankWebhookSignatureUtils.verify( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ), + ).not.toThrow(); + }); + + it("rejects invalid Paystack signatures", async () => { + const { BankWebhookSignatureUtils } = await import( + "@/server/utils/bank-webhook-signature.utils" + ); + const { UnauthorizedError } = await import("@/server/utils/errors"); + + process.env.PAYSTACK_WEBHOOK_SECRET = "paystack-test-secret"; + + expect(() => + BankWebhookSignatureUtils.verify( + BankWebhookProvider.PAYSTACK, + "{}", + "invalid-signature", + ), + ).toThrow(UnauthorizedError); + }); + + it("verifies Stripe signatures within tolerance window", async () => { + const { BankWebhookSignatureUtils } = await import( + "@/server/utils/bank-webhook-signature.utils" + ); + + const secret = "whsec_test_secret"; + process.env.STRIPE_WEBHOOK_SECRET = secret; + + const rawBody = JSON.stringify({ + type: "financial_connections.account.created", + data: { object: { id: "fca_abc123" } }, + }); + + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signedPayload = `${timestamp}.${rawBody}`; + const signature = crypto + .createHmac("sha256", secret) + .update(signedPayload) + .digest("hex"); + + expect(() => + BankWebhookSignatureUtils.verify( + BankWebhookProvider.STRIPE, + rawBody, + `t=${timestamp},v1=${signature}`, + ), + ).not.toThrow(); + }); +}); + +describe("bank webhook utils", () => { + it("extracts Paystack event metadata from payload", async () => { + const { + extractBankWebhookEventType, + extractProviderAccountId, + } = await import("@/server/utils/bank-webhook.utils"); + + const payload = { + event: "dedicatedaccount.assign.success", + data: { + customer: { customer_code: "CUS_xyz789" }, + dedicated_account: { id: 42 }, + }, + }; + + expect( + extractBankWebhookEventType(BankWebhookProvider.PAYSTACK, payload), + ).toBe("dedicatedaccount.assign.success"); + expect( + extractProviderAccountId(BankWebhookProvider.PAYSTACK, payload), + ).toBe("CUS_xyz789"); + }); + + it("extracts Stripe event metadata from payload", async () => { + const { + extractBankWebhookEventType, + extractProviderAccountId, + } = await import("@/server/utils/bank-webhook.utils"); + + const payload = { + type: "financial_connections.account.deactivated", + data: { object: { id: "fca_deactivated" } }, + }; + + expect( + extractBankWebhookEventType(BankWebhookProvider.STRIPE, payload), + ).toBe("financial_connections.account.deactivated"); + expect( + extractProviderAccountId(BankWebhookProvider.STRIPE, payload), + ).toBe("fca_deactivated"); + }); +}); diff --git a/src/app/api/v1/finance/webhooks/bank/route.ts b/src/app/api/v1/finance/webhooks/bank/route.ts new file mode 100644 index 0000000..c84456b --- /dev/null +++ b/src/app/api/v1/finance/webhooks/bank/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { BankWebhookService } from "@/server/services/bank-webhook.service"; +import { Logger } from "@/server/services/logger.service"; +import { + extractBankWebhookSignature, + resolveBankWebhookProvider, +} from "@/server/utils/bank-webhook.utils"; + +export async function POST(req: NextRequest) { + try { + const rawBody = await req.text(); + const provider = resolveBankWebhookProvider(req); + + if (!provider) { + return ApiResponse.error( + "Unable to determine webhook provider", + 400, + null, + req, + ); + } + + const signature = extractBankWebhookSignature(req, provider); + + if (!signature) { + return ApiResponse.error("Missing webhook signature", 401, null, req); + } + + const result = await BankWebhookService.processWebhook( + provider, + rawBody, + signature, + ); + + return ApiResponse.success(result, "Webhook processed successfully"); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error( + error.message, + error.statusCode, + error.errors, + req, + ); + } + + Logger.error("[Bank Webhook Error]", { error: String(error) }); + return ApiResponse.error("Webhook processing failed", 500, null, req); + } +} diff --git a/src/server/constants/bank-webhook.constants.ts b/src/server/constants/bank-webhook.constants.ts new file mode 100644 index 0000000..1d29d74 --- /dev/null +++ b/src/server/constants/bank-webhook.constants.ts @@ -0,0 +1,49 @@ +import { + BankAccountStatus, + BankWebhookProvider, +} from "@/server/enums/bank-account.enum"; + +export const BANK_WEBHOOK_SIGNATURE_HEADERS: Record< + BankWebhookProvider, + string +> = { + [BankWebhookProvider.PAYSTACK]: "x-paystack-signature", + [BankWebhookProvider.STRIPE]: "stripe-signature", +}; + +export const BANK_WEBHOOK_PROVIDER_QUERY_PARAM = "provider"; + +export const BANK_WEBHOOK_EVENT_STATUS_MAP: Record< + BankWebhookProvider, + Record +> = { + [BankWebhookProvider.PAYSTACK]: { + "customeridentification.success": BankAccountStatus.VERIFIED, + "customeridentification.failed": BankAccountStatus.DISCONNECTED, + "dedicatedaccount.assign.success": BankAccountStatus.VERIFIED, + "dedicatedaccount.assign.failed": BankAccountStatus.DISCONNECTED, + "transfer.success": BankAccountStatus.VERIFIED, + "transfer.failed": BankAccountStatus.DISCONNECTED, + "transfer.reversed": BankAccountStatus.DISCONNECTED, + }, + [BankWebhookProvider.STRIPE]: { + "financial_connections.account.created": BankAccountStatus.VERIFIED, + "financial_connections.account.refreshed_balance": BankAccountStatus.VERIFIED, + "financial_connections.account.deactivated": BankAccountStatus.DISCONNECTED, + "financial_connections.account.disconnected": BankAccountStatus.DISCONNECTED, + "account.application.deauthorized": BankAccountStatus.DISCONNECTED, + }, +}; + +export const STRIPE_WEBHOOK_TOLERANCE_SECONDS = 300; + +export const BANK_WEBHOOK_SECRET_ENV_KEYS: Record< + BankWebhookProvider, + string[] +> = { + [BankWebhookProvider.PAYSTACK]: [ + "PAYSTACK_WEBHOOK_SECRET", + "PAYSTACK_SECRET_KEY", + ], + [BankWebhookProvider.STRIPE]: ["STRIPE_WEBHOOK_SECRET"], +}; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index b564b58..af3294b 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -98,6 +98,15 @@ export const fiatProviderEnum = pgEnum("fiat_provider", [ "monnify", "flutterwave", ]); +export const bankAccountStatusEnum = pgEnum("bank_account_status", [ + "pending", + "verified", + "disconnected", +]); +export const bankConnectionProviderEnum = pgEnum("bank_connection_provider", [ + "paystack", + "stripe", +]); export const payrollDraftStatusEnum = pgEnum("payroll_draft_status", [ "active", @@ -414,6 +423,40 @@ export const organizationWallets = pgTable("organization_wallets", { updatedAt: timestamp("updated_at").defaultNow().notNull(), }); +export const bankAccounts = pgTable( + "bank_accounts", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .references(() => organizations.id, { onDelete: "cascade" }) + .notNull(), + provider: bankConnectionProviderEnum("provider").notNull(), + providerAccountId: varchar("provider_account_id", { length: 255 }) + .notNull() + .unique(), + accountNumber: varchar("account_number", { length: 255 }), + bankName: varchar("bank_name", { length: 255 }), + accountHolderName: varchar("account_holder_name", { length: 255 }), + status: bankAccountStatusEnum("status").default("pending").notNull(), + verifiedAt: timestamp("verified_at"), + disconnectedAt: timestamp("disconnected_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("bank_accounts_organization_id_idx").on(table.organizationId), + index("bank_accounts_provider_account_id_idx").on(table.providerAccountId), + index("bank_accounts_status_idx").on(table.status), + ], +); + +export const bankAccountRelations = relations(bankAccounts, (helpers: any) => ({ + organization: helpers.one(organizations, { + fields: [bankAccounts.organizationId], + references: [organizations.id], + }), +})); + export const organizationFiatBalances = pgTable("organization_fiat_balances", { id: uuid("id").primaryKey().defaultRandom(), organizationId: uuid("organization_id") diff --git a/src/server/enums/bank-account.enum.ts b/src/server/enums/bank-account.enum.ts new file mode 100644 index 0000000..ff21892 --- /dev/null +++ b/src/server/enums/bank-account.enum.ts @@ -0,0 +1,10 @@ +export enum BankAccountStatus { + PENDING = "pending", + VERIFIED = "verified", + DISCONNECTED = "disconnected", +} + +export enum BankWebhookProvider { + PAYSTACK = "paystack", + STRIPE = "stripe", +} diff --git a/src/server/services/bank-webhook.service.spec.ts b/src/server/services/bank-webhook.service.spec.ts new file mode 100644 index 0000000..3ca4a32 --- /dev/null +++ b/src/server/services/bank-webhook.service.spec.ts @@ -0,0 +1,168 @@ +import crypto from "crypto"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { BankWebhookService } from "./bank-webhook.service"; +import { + BankAccountStatus, + BankWebhookProvider, +} from "@/server/enums/bank-account.enum"; + +vi.mock("@/server/db", () => ({ + db: { + select: vi.fn(), + update: vi.fn(), + }, +})); + +import { db } from "@/server/db"; + +function mockSelectChain(result: unknown[]) { + const limit = vi.fn().mockResolvedValue(result); + const where = vi.fn().mockReturnValue({ limit }); + const from = vi.fn().mockReturnValue({ where }); + vi.mocked(db.select).mockReturnValue({ from } as never); + return { limit, where, from }; +} + +function mockUpdateChain() { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn().mockReturnValue({ where }); + vi.mocked(db.update).mockReturnValue({ set } as never); + return { set, where }; +} + +describe("BankWebhookService", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv, PAYSTACK_WEBHOOK_SECRET: "paystack-secret" }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("updates bank account status from pending to verified", async () => { + mockSelectChain([ + { id: "bank-account-1", status: BankAccountStatus.PENDING }, + ]); + mockUpdateChain(); + + const rawBody = JSON.stringify({ + event: "customeridentification.success", + data: { customer_code: "CUS_abc123" }, + }); + + const signature = crypto + .createHmac("sha512", "paystack-secret") + .update(rawBody) + .digest("hex"); + + const result = await BankWebhookService.processWebhook( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ); + + expect(result.statusUpdated).toBe(true); + expect(result.status).toBe(BankAccountStatus.VERIFIED); + expect(result.providerAccountId).toBe("CUS_abc123"); + expect(db.update).toHaveBeenCalled(); + }); + + it("updates bank account status to disconnected on failure events", async () => { + mockSelectChain([ + { id: "bank-account-1", status: BankAccountStatus.VERIFIED }, + ]); + mockUpdateChain(); + + const rawBody = JSON.stringify({ + event: "customeridentification.failed", + data: { customer_code: "CUS_failed" }, + }); + + const signature = crypto + .createHmac("sha512", "paystack-secret") + .update(rawBody) + .digest("hex"); + + const result = await BankWebhookService.processWebhook( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ); + + expect(result.statusUpdated).toBe(true); + expect(result.status).toBe(BankAccountStatus.DISCONNECTED); + }); + + it("returns statusUpdated false for unhandled events", async () => { + const rawBody = JSON.stringify({ + event: "charge.success", + data: { reference: "ref_123" }, + }); + + const signature = crypto + .createHmac("sha512", "paystack-secret") + .update(rawBody) + .digest("hex"); + + const result = await BankWebhookService.processWebhook( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ); + + expect(result.statusUpdated).toBe(false); + expect(result.status).toBeNull(); + expect(db.update).not.toHaveBeenCalled(); + }); + + it("returns statusUpdated false when bank account is not found", async () => { + mockSelectChain([]); + + const rawBody = JSON.stringify({ + event: "customeridentification.success", + data: { customer_code: "CUS_missing" }, + }); + + const signature = crypto + .createHmac("sha512", "paystack-secret") + .update(rawBody) + .digest("hex"); + + const result = await BankWebhookService.processWebhook( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ); + + expect(result.statusUpdated).toBe(false); + expect(db.update).not.toHaveBeenCalled(); + }); + + it("skips update when status is unchanged", async () => { + mockSelectChain([ + { id: "bank-account-1", status: BankAccountStatus.VERIFIED }, + ]); + + const rawBody = JSON.stringify({ + event: "customeridentification.success", + data: { customer_code: "CUS_abc123" }, + }); + + const signature = crypto + .createHmac("sha512", "paystack-secret") + .update(rawBody) + .digest("hex"); + + const result = await BankWebhookService.processWebhook( + BankWebhookProvider.PAYSTACK, + rawBody, + signature, + ); + + expect(result.statusUpdated).toBe(false); + expect(db.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/services/bank-webhook.service.ts b/src/server/services/bank-webhook.service.ts new file mode 100644 index 0000000..3715d70 --- /dev/null +++ b/src/server/services/bank-webhook.service.ts @@ -0,0 +1,119 @@ +import { eq } from "drizzle-orm"; +import { BANK_WEBHOOK_EVENT_STATUS_MAP } from "@/server/constants/bank-webhook.constants"; +import { db } from "@/server/db"; +import { bankAccounts } from "@/server/db/schema"; +import { + BankAccountStatus, + BankWebhookProvider, +} from "@/server/enums/bank-account.enum"; +import { Logger } from "@/server/services/logger.service"; +import { BankWebhookSignatureUtils } from "@/server/utils/bank-webhook-signature.utils"; +import { + extractBankWebhookEventType, + extractProviderAccountId, + parseBankWebhookPayload, +} from "@/server/utils/bank-webhook.utils"; + +export interface BankWebhookProcessResult { + received: true; + statusUpdated: boolean; + eventType: string | null; + providerAccountId: string | null; + status: BankAccountStatus | null; +} + +export class BankWebhookService { + static async processWebhook( + provider: BankWebhookProvider, + rawBody: string, + signature: string, + ): Promise { + BankWebhookSignatureUtils.verify(provider, rawBody, signature); + + const payload = parseBankWebhookPayload(rawBody); + const eventType = extractBankWebhookEventType(provider, payload); + const providerAccountId = extractProviderAccountId(provider, payload); + const targetStatus = eventType + ? BANK_WEBHOOK_EVENT_STATUS_MAP[provider][eventType] + : undefined; + + Logger.info("Bank webhook received", { + provider, + eventType, + providerAccountId, + }); + + if (!eventType || !providerAccountId || !targetStatus) { + return { + received: true, + statusUpdated: false, + eventType, + providerAccountId, + status: null, + }; + } + + const statusUpdated = await this.updateBankAccountStatus( + provider, + providerAccountId, + targetStatus, + ); + + return { + received: true, + statusUpdated, + eventType, + providerAccountId, + status: targetStatus, + }; + } + + static async updateBankAccountStatus( + provider: BankWebhookProvider, + providerAccountId: string, + status: BankAccountStatus, + ): Promise { + const [account] = await db + .select({ id: bankAccounts.id, status: bankAccounts.status }) + .from(bankAccounts) + .where(eq(bankAccounts.providerAccountId, providerAccountId)) + .limit(1); + + if (!account) { + Logger.warn("Bank account not found for webhook update", { + provider, + providerAccountId, + status, + }); + return false; + } + + if (account.status === status) { + return false; + } + + const timestamp = new Date(); + + await db + .update(bankAccounts) + .set({ + status, + provider, + verifiedAt: + status === BankAccountStatus.VERIFIED ? timestamp : undefined, + disconnectedAt: + status === BankAccountStatus.DISCONNECTED ? timestamp : undefined, + updatedAt: timestamp, + }) + .where(eq(bankAccounts.id, account.id)); + + Logger.info("Bank account status updated", { + bankAccountId: account.id, + provider, + providerAccountId, + status, + }); + + return true; + } +} diff --git a/src/server/utils/bank-webhook-signature.utils.ts b/src/server/utils/bank-webhook-signature.utils.ts new file mode 100644 index 0000000..df1b20a --- /dev/null +++ b/src/server/utils/bank-webhook-signature.utils.ts @@ -0,0 +1,100 @@ +import crypto from "crypto"; +import { + BANK_WEBHOOK_SECRET_ENV_KEYS, + STRIPE_WEBHOOK_TOLERANCE_SECONDS, +} from "@/server/constants/bank-webhook.constants"; +import { BankWebhookProvider } from "@/server/enums/bank-account.enum"; +import { UnauthorizedError } from "@/server/utils/errors"; + +function resolveSecret(provider: BankWebhookProvider): string { + const envKeys = BANK_WEBHOOK_SECRET_ENV_KEYS[provider]; + + for (const key of envKeys) { + const value = process.env[key]; + if (value) { + return value; + } + } + + throw new UnauthorizedError(`Missing webhook secret for provider: ${provider}`); +} + +function safeCompare(expected: string, received: string): boolean { + if (expected.length !== received.length) { + return false; + } + + return crypto.timingSafeEqual( + Buffer.from(expected, "utf8"), + Buffer.from(received, "utf8"), + ); +} + +function verifyPaystackSignature(rawBody: string, signature: string): void { + const secret = resolveSecret(BankWebhookProvider.PAYSTACK); + const hash = crypto + .createHmac("sha512", secret) + .update(rawBody) + .digest("hex"); + + if (!safeCompare(hash, signature)) { + throw new UnauthorizedError("Invalid Paystack webhook signature"); + } +} + +function verifyStripeSignature(rawBody: string, signatureHeader: string): void { + const secret = resolveSecret(BankWebhookProvider.STRIPE); + const elements = signatureHeader.split(","); + + const timestamp = elements + .find((element) => element.startsWith("t=")) + ?.slice(2); + const signatures = elements + .filter((element) => element.startsWith("v1=")) + .map((element) => element.slice(3)); + + if (!timestamp || signatures.length === 0) { + throw new UnauthorizedError("Invalid Stripe webhook signature format"); + } + + const eventAge = Math.floor(Date.now() / 1000) - Number(timestamp); + if ( + Number.isNaN(eventAge) || + eventAge > STRIPE_WEBHOOK_TOLERANCE_SECONDS + ) { + throw new UnauthorizedError("Stripe webhook timestamp outside tolerance"); + } + + const signedPayload = `${timestamp}.${rawBody}`; + const expectedSignature = crypto + .createHmac("sha256", secret) + .update(signedPayload) + .digest("hex"); + + const isValid = signatures.some((signature) => + safeCompare(expectedSignature, signature), + ); + + if (!isValid) { + throw new UnauthorizedError("Invalid Stripe webhook signature"); + } +} + +export class BankWebhookSignatureUtils { + static verify( + provider: BankWebhookProvider, + rawBody: string, + signature: string, + ): void { + switch (provider) { + case BankWebhookProvider.PAYSTACK: + verifyPaystackSignature(rawBody, signature); + return; + case BankWebhookProvider.STRIPE: + verifyStripeSignature(rawBody, signature); + return; + default: + throw new UnauthorizedError(`Unsupported webhook provider: ${provider}`); + } + } +} diff --git a/src/server/utils/bank-webhook.utils.ts b/src/server/utils/bank-webhook.utils.ts new file mode 100644 index 0000000..f3df946 --- /dev/null +++ b/src/server/utils/bank-webhook.utils.ts @@ -0,0 +1,132 @@ +import { NextRequest } from "next/server"; +import { + BANK_WEBHOOK_PROVIDER_QUERY_PARAM, + BANK_WEBHOOK_SIGNATURE_HEADERS, +} from "@/server/constants/bank-webhook.constants"; +import { BankWebhookProvider } from "@/server/enums/bank-account.enum"; +import { BadRequestError } from "@/server/utils/errors"; + +function isBankWebhookProvider(value: string): value is BankWebhookProvider { + return Object.values(BankWebhookProvider).includes(value as BankWebhookProvider); +} + +export function resolveBankWebhookProvider( + req: NextRequest, +): BankWebhookProvider | null { + for (const provider of Object.values(BankWebhookProvider)) { + const headerName = BANK_WEBHOOK_SIGNATURE_HEADERS[provider]; + if (req.headers.get(headerName)) { + return provider; + } + } + + const queryProvider = req.nextUrl.searchParams.get( + BANK_WEBHOOK_PROVIDER_QUERY_PARAM, + ); + + if (queryProvider && isBankWebhookProvider(queryProvider)) { + return queryProvider; + } + + return null; +} + +export function extractBankWebhookSignature( + req: NextRequest, + provider: BankWebhookProvider, +): string | null { + return req.headers.get(BANK_WEBHOOK_SIGNATURE_HEADERS[provider]); +} + +export function extractBankWebhookEventType( + provider: BankWebhookProvider, + payload: Record, +): string | null { + if (provider === BankWebhookProvider.PAYSTACK) { + return typeof payload.event === "string" ? payload.event : null; + } + + if (provider === BankWebhookProvider.STRIPE) { + return typeof payload.type === "string" ? payload.type : null; + } + + return null; +} + +export function extractProviderAccountId( + provider: BankWebhookProvider, + payload: Record, +): string | null { + const data = payload.data; + + if (!data || typeof data !== "object") { + return null; + } + + const eventData = data as Record; + + if (provider === BankWebhookProvider.PAYSTACK) { + if (typeof eventData.customer_code === "string") { + return eventData.customer_code; + } + + const customer = eventData.customer; + if ( + customer && + typeof customer === "object" && + typeof (customer as Record).customer_code === "string" + ) { + return (customer as Record).customer_code; + } + + const dedicatedAccount = eventData.dedicated_account; + if (dedicatedAccount && typeof dedicatedAccount === "object") { + const accountId = (dedicatedAccount as Record).id; + if (accountId !== undefined && accountId !== null) { + return String(accountId); + } + } + + const transfer = eventData.transfer; + if (transfer && typeof transfer === "object") { + const transferCode = (transfer as Record).transfer_code; + if (typeof transferCode === "string") { + return transferCode; + } + } + + return null; + } + + if (provider === BankWebhookProvider.STRIPE) { + const object = eventData.object; + if (object && typeof object === "object") { + const accountId = (object as Record).id; + if (typeof accountId === "string") { + return accountId; + } + } + + return null; + } + + return null; +} + +export function parseBankWebhookPayload(rawBody: string): Record { + try { + const payload = JSON.parse(rawBody) as unknown; + + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new BadRequestError("Invalid webhook payload"); + } + + return payload as Record; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError("Invalid JSON in webhook payload"); + } +}