# Tazapay Provider Implementation Plan ## Overview This document provides a detailed implementation plan for integrating Tazapay as a uRamp provider, supporting both onramp and offramp flows for the following currencies: **Fiat Currencies**: USD, SGD, EUR, INR, BRL, PHP, THB, MXN, VND **Crypto Currencies**: USDC, USDT (on Ethereum, Polygon, Tron, Solana) --- ## === FILE STRUCTURE === ### 1. File-by-File Breakdown ``` packages/providers/tazapay/ ├── package.json # Package configuration ├── tsconfig.json # TypeScript configuration ├── manifest.ts # Plugin manifest with capabilities ├── index.ts # Main exports │ ├── client/ # API Client Layer │ ├── tazapay.api.service.ts # Tazapay API client │ ├── credential.schema.ts # Credential form schema │ ├── interfaces.ts # TypeScript interfaces │ └── config.ts # Configuration constants │ ├── api-server/ # Server-side hooks │ ├── handlers/ │ │ ├── index.ts # Handler exports (collectRequirements, preCheck, check, postCheck) │ │ ├── tazapay-create-entity.ts # Entity creation handler (KYB) │ │ ├── tazapay-verify-entity.ts # Entity verification handler │ │ ├── tazapay-create-customer.ts # Customer creation handler (payins) │ │ └── tazapay-create-beneficiary.ts # Beneficiary creation handler │ ├── types.ts # Server-side types │ └── webhook-module.ts # Webhook handling │ ├── money-movement/ # Money movement hooks │ ├── index.ts # Exports createTransaction │ └── transaction.ts # Transaction implementation │ └── dashboard/ # Dashboard components ├── index.ts # Dashboard module export └── handlers/ ├── index.ts # Handler exports ├── TazapayOnboarding.tsx # Onboarding UI component ├── tazapay-onboarding.ts # Onboarding server action └── tazapay-onboarding.schema.ts # Form schemas ``` --- ### File Details #### `manifest.ts` **Purpose**: Define plugin metadata, capabilities, and requirements **Key Exports**: `manifest: PluginManifest` ```typescript // Pseudocode export const manifest: PluginManifest = { id: 'TAZAPAY', version: '1.0.0', name: 'tazapay', description: 'Tazapay cross-border payment provider', requiredWebhookKeys: ['events'], webhookRegistrationMethod: WebhookRegistrationMethod.DASHBOARD, credentialSchema: { jsonSchema, uiSchema }, capabilities: [ // Onramp: Fiat -> USDC/USDT { src: 'USD', dest: 'USDC_ETH', priority: 100 }, { src: 'USD', dest: 'USDC_POLYGON', priority: 100 }, // ... all currency pairs // Offramp: USDC/USDT -> Fiat { src: 'USDC_ETH', dest: 'USD', priority: 100 }, // ... etc ], requirementsByEdgeType: { KYC_SERVICE_PROVIDER: [ { code: 'TAZAPAY_CREATE_ENTITY', description: 'Create Tazapay Entity' }, { code: 'TAZAPAY_VERIFY_ENTITY', description: 'Verify Entity KYB', dependencies: ['TAZAPAY_CREATE_ENTITY'] }, ], }, }; ``` --- #### `client/tazapay.api.service.ts` **Purpose**: HTTP client for Tazapay API **Key Exports**: `TazapayApiService` class ```typescript // Pseudocode for key methods class TazapayApiService { constructor(config: TazapayConfig) { ... } // Authentication: HTTP Basic Auth private _getAuthHeader(): string { return `Basic ${Buffer.from(`${apiKey}:${apiSecret}`).toString('base64')}`; } // Entity (KYB) APIs async createEntity(request: EntityCreateRequest): Promise<EntityResponse> { ... } async getEntity(entityId: string): Promise<EntityResponse> { ... } async submitEntity(entityId: string): Promise<EntityResponse> { ... } // Customer APIs async createCustomer(request: CustomerCreateRequest): Promise<CustomerResponse> { ... } async getCustomer(customerId: string): Promise<CustomerResponse> { ... } // Beneficiary APIs async createBeneficiary(request: BeneficiaryCreateRequest): Promise<BeneficiaryResponse> { ... } async getBeneficiary(beneficiaryId: string): Promise<BeneficiaryResponse> { ... } // Payout APIs async createPayoutQuote(request: PayoutQuoteRequest): Promise<PayoutQuoteResponse> { ... } async createPayout(request: PayoutCreateRequest): Promise<PayoutResponse> { ... } async getPayout(payoutId: string): Promise<PayoutResponse> { ... } // Checkout/Payin APIs async createCheckout(request: CheckoutCreateRequest): Promise<CheckoutResponse> { ... } async getCheckout(checkoutId: string): Promise<CheckoutResponse> { ... } // Balance/FX APIs async getBalance(): Promise<BalanceResponse> { ... } async getFxQuote(request: FxQuoteRequest): Promise<FxQuoteResponse> { ... } // Collection Account APIs async getCollectionAccounts(): Promise<CollectionAccountResponse[]> { ... } } ``` --- #### `client/interfaces.ts` **Purpose**: TypeScript type definitions for all API interactions **Key Exports**: All request/response interfaces ```typescript // Key interfaces (see INSIGHTS.md Section 11 for full schemas) interface TazapayCredentials { apiKey, apiSecret, webhookSecret, baseUrl } interface EntityCreateRequest { name, type, email, ... } interface EntityResponse { id, approval_status, ... } interface BeneficiaryCreateRequest { name, type, destination_details, ... } interface PayoutQuoteRequest { payout_type, payout_info, ... } interface PayoutCreateRequest { amount, currency, beneficiary, ... } interface CheckoutCreateRequest { invoice_currency, amount, customer_details, ... } interface WebhookPayload { type, id, created_at, data } ``` --- #### `api-server/handlers/index.ts` **Purpose**: Dynamic handler loading and requirement collection **Key Exports**: `collectRequirements`, `preCheck`, `check`, `postCheck` ```typescript // Pseudocode export const collectRequirements = async (intent, edge, context) => { const requirements = manifest.requirementsByEdgeType?.[edge.type]; if (!requirements) return []; return requirements.map(req => ({ code: req.code, description: req.description, dependencies: req.dependencies, complianceEdge: { connect: { id: edge.id } }, compliancePlan: { connect: { id: intent.compliancePlan!.id } }, })); }; // Dynamic handler dispatch async function _executeHandler(phase, intent, requirement, context) { const fileName = requirementCodeToHandlerFileName(requirement.code); const functionName = phase + requirementCodeToHandlerFunctionSuffix(requirement.code); const handlerModule = await import(`./${fileName}.js`); const handler = handlerModule[functionName]; if (!handler) return intent; return handler(intent, requirement, context); } export const preCheck = (intent, req, ctx) => _executeHandler('preCheck', intent, req, ctx); export const check = (intent, req, ctx) => _executeHandler('check', intent, req, ctx); export const postCheck = (intent, req, ctx) => _executeHandler('postCheck', intent, req, ctx); ``` --- #### `api-server/handlers/tazapay-create-entity.ts` **Purpose**: Create Tazapay Entity for KYB **Key Exports**: `checkTazapayCreateEntity`, `postCheckTazapayCreateEntity` ```typescript // Pseudocode export async function checkTazapayCreateEntity(intent, requirement, context) { const credentials = await context.getCredentials(); const client = new TazapayApiService(credentials); // Check if entity already exists in CustomerProvider const customerProvider = await getCustomerProvider(prisma, customerId, 'tazapay'); if (customerProvider?.providerReferenceId) { // Entity exists, verify status const entity = await client.getEntity(customerProvider.providerReferenceId); if (entity.approval_status === 'approved') return intent; // Done if (entity.approval_status === 'rejected') throw new Error('KYB rejected'); } // Create new entity const entityRequest = buildEntityRequest(intent); const entity = await client.createEntity(entityRequest); // Store entity ID await upsertCustomerProvider(prisma, customerId, 'tazapay', { providerReferenceId: entity.id, onboardingData: { entityId: entity.id, kybStatus: entity.approval_status }, }); // Store in intent.metadata for downstream handlers await context.prisma.intent.update({ where: { id: intent.id }, data: { metadata: { ...intent.metadata, tazapay_entity_id: entity.id } }, }); return intent; } export async function postCheckTazapayCreateEntity(intent, requirement, context) { // Propagate entity ID to dependent steps const entityId = (requirement.metadata as any)?.entityId; // ...propagate to TAZAPAY_VERIFY_ENTITY step return intent; } ``` --- #### `api-server/handlers/tazapay-verify-entity.ts` **Purpose**: Verify Entity KYB status **Key Exports**: `checkTazapayVerifyEntity` ```typescript // Pseudocode export async function checkTazapayVerifyEntity(intent, requirement, context) { const credentials = await context.getCredentials(); const client = new TazapayApiService(credentials); const entityId = (intent.metadata as any)?.tazapay_entity_id; if (!entityId) throw new Error('Entity ID not found'); const entity = await client.getEntity(entityId); switch (entity.approval_status) { case 'approved': // Update CustomerProvider status await updateCustomerProviderById(prisma, customerProvider.id, { onboardingData: { kybStatus: 'approved', approvedAt: new Date().toISOString() }, }); return intent; case 'pending': throw new Error('KYB pending additional documents'); case 'rejected': throw new Error(`KYB rejected: ${entity.rejection_reason}`); case 'submitted': case 'initiated': throw new Error('KYB still under review'); default: throw new Error(`Unknown KYB status: ${entity.approval_status}`); } } ``` --- #### `api-server/webhook-module.ts` **Purpose**: Handle incoming webhooks from Tazapay **Key Exports**: `webhookModule: WebhookModule` ```typescript // Pseudocode export const webhookModule: WebhookModule = { async authenticate(authContext, pluginContext): Promise<string | null> { const { request } = authContext; const credentials = await pluginContext.getCredentials(); // Verify HMAC-SHA256 signature const payload = JSON.stringify(request.body); const signature = request.headers['x-tazapay-signature']; const eventId = request.body.id; const createdAt = request.body.created_at; if (!_verifySignature(payload, signature, credentials.webhookSecret, eventId, createdAt)) { return null; } // Check timestamp for replay attack if (!_isTimestampValid(createdAt, 10 * 60 * 1000)) return null; // Find intent by provider transaction ID const data = request.body.data; const intent = await _findIntentByProviderRef(pluginContext.prisma, data.id); return intent?.id ?? null; }, async handle(handleContext, pluginContext): Promise<void> { const { intentId, webhookEvent } = handleContext; const payload = webhookEvent.eventData; const eventType = payload.type; // Route by event type if (eventType.startsWith('payout.')) { await _handlePayoutWebhook(intentId, payload, pluginContext); } else if (eventType.startsWith('payin.')) { await _handlePayinWebhook(intentId, payload, pluginContext); } else if (eventType.startsWith('entity.')) { await _handleEntityWebhook(intentId, payload, pluginContext); } else if (eventType.startsWith('collect.')) { await _handleCollectWebhook(intentId, payload, pluginContext); } }, }; // Helper: HMAC-SHA256 verification per Tazapay docs function _verifySignature(payload, signature, secret, eventId, createdAt): boolean { const signatureData = `${eventId}${payload}${createdAt}`; const expected = crypto.createHmac('sha256', secret).update(signatureData).digest('base64'); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` --- #### `money-movement/transaction.ts` **Purpose**: Execute onramp and offramp transactions **Key Exports**: `createTransaction` ```typescript // Pseudocode export async function createTransaction( context: PluginContext, transactionContext: TransactionContext, ): Promise<PluginTransactionResult> { const credentials = await context.getCredentials(); const client = new TazapayApiService(credentials); const { intent, externalAccounts, amount } = transactionContext; const isOfframp = externalAccounts.src.type === ExternalAccountType.CRYPTO_ADDRESS; // Check for existing transaction (idempotency) const existingTxnId = (intent.metadata as any)?.tazapay_payout_id || (intent.metadata as any)?.tazapay_checkout_id; if (existingTxnId) { return _handleExistingTransaction(client, existingTxnId, isOfframp); } if (isOfframp) { return _handleOfframp(client, context, transactionContext); } else { return _handleOnramp(client, context, transactionContext); } } async function _handleOfframp(client, context, transactionContext): Promise<PluginTransactionResult> { const { intent, externalAccounts, amount } = transactionContext; // 1. Get or create beneficiary const beneficiaryId = await _getOrCreateBeneficiary(client, context, externalAccounts.dest); // 2. Create payout quote const quote = await client.createPayoutQuote({ payout_type: _determinePayoutType(externalAccounts.dest), payout_info: { currency: amount.currency.code, amount: _toCents(amount.amount) }, holding_info: { currency: _getHoldingCurrency(externalAccounts.src) }, }); // 3. Create payout const payout = await client.createPayout({ amount: _toCents(amount.amount), currency: amount.currency.code, beneficiary: beneficiaryId, quote: quote.id, purpose: 'PYR001', // Generic purpose code transaction_description: `Offramp ${intent.id}`, reference_id: intent.id, }); // 4. Store provider reference await context.prisma.intent.update({ where: { id: intent.id }, data: { metadata: { ...(intent.metadata || {}), tazapay_payout_id: payout.id, tazapay_beneficiary_id: beneficiaryId } }, }); // 5. Return deposit instructions for crypto return { status: MoneyMovementTransactionStatus.IN_PROGRESS, depositInstructions: _buildCryptoDepositInstructions(externalAccounts.src, await _getCollectionWallet(client)), }; } async function _handleOnramp(client, context, transactionContext): Promise<PluginTransactionResult> { const { intent, externalAccounts, amount } = transactionContext; // 1. Create checkout session const checkout = await client.createCheckout({ invoice_currency: amount.currency.code, amount: _toCents(amount.amount), customer_details: _buildCustomerDetails(intent), success_url: `${process.env.APP_URL}/checkout/success?intent=${intent.id}`, cancel_url: `${process.env.APP_URL}/checkout/cancel?intent=${intent.id}`, transaction_description: `Onramp ${intent.id}`, reference_id: intent.id, }); // 2. Store provider reference await context.prisma.intent.update({ where: { id: intent.id }, data: { metadata: { ...(intent.metadata || {}), tazapay_checkout_id: checkout.id, tazapay_checkout_url: checkout.url } }, }); // 3. Return hosted checkout instructions return { status: MoneyMovementTransactionStatus.IN_PROGRESS, depositInstructions: [{ kind: 'HOSTED', label: `Complete payment via Tazapay`, hostedUrl: checkout.url, metadata: { checkoutId: checkout.id, expiresAt: checkout.expires_at }, }], }; } ``` --- ## === SEQUENCE DIAGRAMS === ### 2. Flow Diagrams #### a) Onboarding Flow (KYB) ```mermaid sequenceDiagram participant User participant Dashboard participant API Server participant Tazapay API participant Webhook User->>Dashboard: Start onboarding Dashboard->>API Server: Create Intent API Server->>API Server: collectRequirements() Note over API Server: Returns TAZAPAY_CREATE_ENTITY, TAZAPAY_VERIFY_ENTITY API Server->>API Server: check(TAZAPAY_CREATE_ENTITY) API Server->>Tazapay API: POST /v3/entity Tazapay API-->>API Server: { id: "ent_xxx", approval_status: "initiated" } API Server->>API Server: Store entityId in CustomerProvider API Server->>API Server: Update intent.metadata API Server->>Tazapay API: POST /v3/entity/{id}/submit Tazapay API-->>API Server: { approval_status: "submitted" } Note over Tazapay API: Async KYB review... Tazapay API->>Webhook: POST entity.approved Webhook->>API Server: webhookModule.authenticate() Webhook->>API Server: webhookModule.handle() API Server->>API Server: Update CustomerProvider.onboardingData API Server->>API Server: check(TAZAPAY_VERIFY_ENTITY) API Server->>Tazapay API: GET /v3/entity/{id} Tazapay API-->>API Server: { approval_status: "approved" } API Server-->>Dashboard: KYB Complete Dashboard-->>User: Onboarding complete ``` #### b) Quote Flow ```mermaid sequenceDiagram participant User participant API Server participant Tazapay API User->>API Server: Request quote (100 USD -> EUR bank) API Server->>Tazapay API: POST /v3/payout/quote Note over API Server,Tazapay API: { payout_type: "local", payout_info: { currency: "USD", amount: 10000 }, destination_info: { currency: "EUR" } } Tazapay API-->>API Server: PayoutQuoteResponse Note over Tazapay API,API Server: { id: "poq_xxx", holding_info: { amount: 10000 }, destination_info: { amount: 9200 }, fee_info: { ... }, exchange_rates: { ... }, valid_until: "..." } API Server->>API Server: Store quote details API Server-->>User: Quote response Note over User: Quote valid ~30 min ``` #### c) Transaction Flow (Offramp: Crypto -> Fiat) ```mermaid sequenceDiagram participant User participant API Server participant Tazapay API participant Blockchain participant Webhook User->>API Server: Confirm offramp (USDC -> USD bank) API Server->>API Server: createTransaction() API Server->>Tazapay API: POST /v3/beneficiary (if needed) Tazapay API-->>API Server: { id: "bnf_xxx" } API Server->>Tazapay API: POST /v3/payout/quote Tazapay API-->>API Server: { id: "poq_xxx", valid_until: "..." } API Server->>Tazapay API: POST /v3/payout Note over API Server,Tazapay API: { amount, currency, beneficiary: "bnf_xxx", quote: "poq_xxx", ... } Tazapay API-->>API Server: { id: "pot_xxx", status: "requires_funding" } API Server->>API Server: Store pot_xxx in intent.metadata API Server-->>User: Deposit instructions (collection wallet address) User->>Blockchain: Send USDC to collection wallet Blockchain-->>Tazapay API: Funds received Tazapay API->>Webhook: POST collect.succeeded Webhook->>API Server: Update transaction status Tazapay API->>Webhook: POST payout.processing Webhook->>API Server: Status: IN_PROGRESS Note over Tazapay API: Process payout... Tazapay API->>Webhook: POST payout.succeeded Webhook->>API Server: Status: SUCCESS API Server->>API Server: Update MoneyMovementTransaction API Server-->>User: Transaction complete ``` #### d) Transaction Flow (Onramp: Fiat -> Crypto) ```mermaid sequenceDiagram participant User participant Dashboard participant API Server participant Tazapay API participant Hosted Checkout participant Webhook User->>API Server: Initiate onramp (USD card -> USDC) API Server->>API Server: createTransaction() API Server->>Tazapay API: POST /v3/checkout Note over API Server,Tazapay API: { invoice_currency: "USD", amount: 10000, customer_details: {...}, success_url, cancel_url } Tazapay API-->>API Server: { id: "chk_xxx", url: "https://checkout.tazapay.com/..." } API Server->>API Server: Store chk_xxx in intent.metadata API Server-->>Dashboard: Redirect to hosted checkout Dashboard-->>User: Show checkout link User->>Hosted Checkout: Complete payment Hosted Checkout-->>User: Redirect to success_url Tazapay API->>Webhook: POST payin.succeeded Webhook->>API Server: webhookModule.handle() API Server->>API Server: Funds in balance Note over API Server: Trigger payout to user's wallet API Server->>Tazapay API: POST /v3/payout (type: wallet) Tazapay API-->>API Server: { id: "pot_xxx" } Tazapay API->>Webhook: POST payout.succeeded Webhook->>API Server: Status: SUCCESS API Server-->>User: USDC received ``` #### e) Webhook Processing Flow ```mermaid sequenceDiagram participant Tazapay participant Webhook Endpoint participant webhookModule participant Database participant Temporal Tazapay->>Webhook Endpoint: POST /webhook/tazapay/:projectId/:webhookKey Note over Tazapay,Webhook Endpoint: Headers: x-tazapay-signature<br>Body: { type: "payout.succeeded", id: "evt_xxx", created_at, data: {...} } Webhook Endpoint->>webhookModule: authenticate(request) webhookModule->>webhookModule: Verify HMAC-SHA256 signature Note over webhookModule: signature_data = event_id + payload + created_at<br>expected = HMAC-SHA256(secret, signature_data) webhookModule->>webhookModule: Check timestamp (< 10 min) webhookModule->>Database: Find intent by provider reference Note over webhookModule,Database: Query intent.metadata.tazapay_payout_id webhookModule-->>Webhook Endpoint: Return intentId Webhook Endpoint->>Database: Create WebhookEvent record Webhook Endpoint-->>Tazapay: 200 OK Note over Webhook Endpoint: Async processing begins Webhook Endpoint->>webhookModule: handle(intentId, webhookEvent) webhookModule->>Database: Update MoneyMovementTransaction status Note over webhookModule,Database: payout.succeeded -> SUCCESS<br>payout.failed -> FAILED webhookModule->>Database: Update intent.metadata with tracking details webhookModule->>Database: Mark WebhookEvent as COMPLETED webhookModule->>Temporal: triggerCheckOnce(intentId) Note over Temporal: Workflow advancement ``` --- ## === DATA MODEL DESIGN === ### 3. Persistence Schema #### a) CustomerProvider.onboardingData Structure ```typescript interface TazapayOnboardingData { // Entity (KYB) tracking entityId?: string; // ent_xxx - Tazapay entity ID entityType?: 'individual' | 'sole_proprietorship' | 'company' | 'non_profit' | 'government_entity'; kybStatus?: 'initiated' | 'submitted' | 'pending' | 'approved' | 'rejected'; kybSubmittedAt?: string; // ISO 8601 timestamp kybApprovedAt?: string; // ISO 8601 timestamp kybRejectedAt?: string; // ISO 8601 timestamp kybRejectionReason?: string; // Reason if rejected pendingDocuments?: string[]; // Document types still needed // Customer (payin) tracking customerId?: string; // cus_xxx - Tazapay customer ID // Beneficiary cache beneficiaries?: { id: string; // bnf_xxx type: 'bank' | 'wallet' | 'local_payment_network'; currency: string; // e.g., "USD", "EUR" destinationType: string; // e.g., "swift", "local", "upi" destinationId: string; // bnk_xxx or wal_xxx createdAt: string; isVerified: boolean; }[]; // Collection accounts collectionAccounts?: { id: string; // cva_xxx paymentMethodType: string; // e.g., "stablecoin_usdc" currency: string; chain?: string; // For crypto: ethereum, polygon, etc. depositAddress?: string; // For crypto collection virtualAccount?: { accountNumber: string; bankName: string; routingCodes: Record<string, string>; }; createdAt: string; }[]; // Last webhook event tracking lastWebhookEventId?: string; lastWebhookEventAt?: string; } ``` #### b) Intent.metadata Fields **Fields READ from intent.metadata:** ```typescript interface TazapayIntentMetadataRead { // Customer info (populated by uRamp) customer_email?: string; customer_name?: string; customer_country?: string; customer_phone?: { calling_code: string; number: string }; // Destination account details destination_bank?: { account_number: string; bank_codes: Record<string, string>; country: string; currency: string; }; destination_wallet?: { address: string; chain: string; currency: string; }; // Source details for onramp source_payment_method?: string; // card, bank_transfer, etc. } ``` **Fields WRITTEN to intent.metadata:** ```typescript interface TazapayIntentMetadataWrite { // Entity tracking tazapay_entity_id?: string; // ent_xxx // Customer tracking tazapay_customer_id?: string; // cus_xxx // Beneficiary tracking tazapay_beneficiary_id?: string; // bnf_xxx // Transaction tracking - Payout (offramp) tazapay_payout_id?: string; // pot_xxx tazapay_payout_quote_id?: string; // poq_xxx tazapay_payout_status?: string; // Provider status // Transaction tracking - Checkout (onramp) tazapay_checkout_id?: string; // chk_xxx tazapay_checkout_url?: string; // Hosted checkout URL tazapay_payin_id?: string; // pay_xxx (linked to checkout) // Collection tracking tazapay_collect_id?: string; // col_xxx tazapay_collection_account_id?: string; // FX tracking tazapay_fx_quote_id?: string; // fx_xxx tazapay_exchange_rate?: number; // Tracking/settlement info tazapay_tracking_number?: string; tazapay_tracking_type?: 'uetr' | 'utr' | 'transaction_hash' | 'pix_id'; // Completion data tazapay_completed_at?: string; tazapay_settlement_date?: string; tazapay_fee_amount?: number; tazapay_fee_currency?: string; } ``` #### c) CompliancePlanStep.metadata Fields ```typescript interface TazapayStepMetadata { // For TAZAPAY_CREATE_ENTITY step entityId?: string; entityType?: string; submittedAt?: string; // For TAZAPAY_VERIFY_ENTITY step verificationAttempts?: number; lastVerificationAt?: string; approvalStatus?: string; // For TAZAPAY_CREATE_CUSTOMER step customerId?: string; // For TAZAPAY_CREATE_BENEFICIARY step beneficiaryId?: string; beneficiaryType?: 'bank' | 'wallet' | 'local_payment_network'; isVerificationRequired?: boolean; verificationStatus?: string; // Error tracking lastError?: string; errorCode?: string; retryCount?: number; } ``` #### d) MoneyMovementTransaction.context Fields ```typescript interface TazapayTransactionContext { // Payout tracking payoutId?: string; // pot_xxx payoutQuoteId?: string; // poq_xxx payoutType?: 'local' | 'swift' | 'wallet' | 'local_payment_network'; payoutStatus?: string; // Provider status string // Payin tracking payinId?: string; // pay_xxx checkoutId?: string; // chk_xxx checkoutUrl?: string; paymentMethod?: string; // card, bank_transfer, etc. // Collect tracking collectId?: string; // col_xxx collectionAccountId?: string; // cva_xxx // Beneficiary info beneficiaryId?: string; // bnf_xxx beneficiaryType?: 'bank' | 'wallet' | 'local_payment_network'; // FX info fxQuoteId?: string; // fx_xxx holdingCurrency?: string; destinationCurrency?: string; exchangeRate?: number; // Fee breakdown fees?: { fixed?: { amount: number; currency: string }; variable?: { percentage: number; amount: number; currency: string }; total?: number; }; // Tracking details trackingDetails?: { trackingNumber: string; trackingType: 'uetr' | 'utr' | 'transaction_hash' | 'pix_id'; }; // Balance info balanceTransactionId?: string; // btr_xxx availableBalance?: number; isBalanceSufficient?: boolean; // Timestamps providerCreatedAt?: string; providerCompletedAt?: string; settlementDate?: string; // Error info (if failed) failureReason?: string; failureCode?: string; isRetryable?: boolean; } ``` --- ## === EDGE CASES & ERROR HANDLING === ### 4. Edge Case Catalog | Scenario | Detection | Handling | Recovery | |----------|-----------|----------|----------| | **API timeout** | HTTP timeout (>30s) | Retry with exponential backoff | Max 3 retries, then fail with PROVIDER_TIMEOUT | | **Rate limited (429)** | HTTP 429 response | Exponential backoff starting at 1s | Max delay 30s, max 5 retries | | **Server error (5xx)** | HTTP 500/502/503/504 | Retry with backoff | Max 3 retries, log for ops investigation | | **Invalid credentials** | HTTP 401 | No retry, fail immediately | Alert ops, require credential update | | **Insufficient permissions** | HTTP 403 | No retry | Check API key permissions in dashboard | | **Entity not found** | HTTP 404 on GET /v3/entity | Check CustomerProvider for stale ID | Re-create entity if needed | | **Duplicate entity** | Error code on POST /v3/entity | Extract existing entity ID from error | Use existing entity, update CustomerProvider | | **KYB rejected** | `entity.rejected` webhook or approval_status | Mark step failed, notify user | Manual review, allow re-submission | | **KYB pending docs** | `entity.pending` webhook or approval_status | Mark step ACTION_REQUIRED | Prompt user for additional documents | | **Quote expired** | HTTP 422 with code 20299 | Create new quote | Auto-refresh quote on retry | | **Insufficient balance** | Error code 20199 or `requires_funding` status | Wait for deposit | Poll collection account, retry payout | | **Amount outside limits** | Error code 20298 | Fail with clear message | Inform user of min/max limits | | **Invalid bank details** | Error codes 3524, 3529 | Fail with validation error | Prompt user to correct details | | **Beneficiary verification required** | `is_doc_verification_required: true` | Mark step ACTION_REQUIRED | Prompt user for wallet verification docs | | **Webhook signature invalid** | HMAC mismatch | Reject webhook, log warning | Alert ops if persistent | | **Webhook replay attack** | Timestamp > 10 minutes old | Reject webhook | Log for investigation | | **Duplicate webhook** | Same event_id already processed | Idempotent skip | Return 200 OK, no action | | **Transaction already exists** | provider_transaction_id in intent.metadata | Return existing transaction status | Idempotent response | | **Payout reversed** | `payout.reversed` webhook | Mark FAILED, store reason | Manual ops intervention | | **Partial completion** | N/A (Tazapay doesn't support partial) | Full amount or failure | Standard retry/fail flow | | **Collection wallet mismatch** | Deposit to wrong address | Cannot auto-detect | Manual reconciliation | | **Wrong amount deposited** | Amount differs from expected | Depends on provider handling | May need manual intervention | ### 5. Idempotency Strategy #### Transaction Creation Idempotency ```typescript // Before creating any provider resource, check if it already exists async function createTransaction(...) { // Check for existing payout const existingPayoutId = (intent.metadata as any)?.tazapay_payout_id; if (existingPayoutId) { const payout = await client.getPayout(existingPayoutId); return _mapPayoutToResult(payout); } // Check for existing checkout const existingCheckoutId = (intent.metadata as any)?.tazapay_checkout_id; if (existingCheckoutId) { const checkout = await client.getCheckout(existingCheckoutId); return _mapCheckoutToResult(checkout); } // Create new transaction... } ``` #### API Request Idempotency ```typescript // Use Idempotency-Key header for all POST requests async function _post<T>(endpoint: string, body: any, idempotencyKey?: string): Promise<T> { const headers = { 'Authorization': this._getAuthHeader(), 'Content-Type': 'application/json', ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }), }; return this._request('POST', endpoint, body, headers); } // Generate idempotency keys from intent + operation function _generateIdempotencyKey(intentId: string, operation: string): string { return `${intentId}-${operation}-${Date.now()}`.slice(0, 255); } // Usage in createPayout const idempotencyKey = _generateIdempotencyKey(intent.id, 'create-payout'); const payout = await client.createPayout(request, idempotencyKey); ``` #### Webhook Idempotency ```typescript async function handle(handleContext, pluginContext): Promise<void> { const { webhookEvent } = handleContext; const eventId = webhookEvent.eventData?.id; // Check if already processed (Tazapay event IDs are unique) const existing = await pluginContext.prisma.webhookEvent.findFirst({ where: { eventType: { startsWith: 'tazapay.' }, eventData: { path: ['id'], equals: eventId }, status: 'COMPLETED', }, }); if (existing) { pluginContext.logger.info('Duplicate webhook event, skipping', { eventId }); return; } // Process webhook... } ``` #### Retry Safety | Operation | Idempotency Method | Key Format | |-----------|-------------------|------------| | Create Entity | Check CustomerProvider.providerReferenceId | N/A (check before create) | | Create Beneficiary | Check intent.metadata.tazapay_beneficiary_id | N/A (check before create) | | Create Payout | Idempotency-Key header | `{intentId}-payout-{timestamp}` | | Create Checkout | Idempotency-Key header | `{intentId}-checkout-{timestamp}` | | Create Quote | No idempotency needed | N/A (quotes are cheap, short-lived) | | Webhook Handle | Check webhookEvent by eventId | Provider event ID | --- ## === TEST SPECIFICATIONS === ### 6. Mock Server Test Scenarios #### Endpoints to Mock | Endpoint | Method | Mock Scenarios | |----------|--------|----------------| | `/v3/entity` | POST | Success (201), Duplicate (409), Validation error (422) | | `/v3/entity/{id}` | GET | Found (200), Not found (404) | | `/v3/entity/{id}/submit` | POST | Success (200), Already submitted (400) | | `/v3/customer` | POST | Success (201), Duplicate (409) | | `/v3/customer/{id}` | GET | Found (200), Not found (404) | | `/v3/beneficiary` | POST | Success (201), Validation error (422), Verification required | | `/v3/beneficiary/{id}` | GET | Found (200), Not found (404) | | `/v3/payout/quote` | POST | Success (200), Invalid params (400), Amount limits (422) | | `/v3/payout` | POST | Success (201), Quote expired (422), Insufficient balance (422) | | `/v3/payout/{id}` | GET | All status variants | | `/v3/checkout` | POST | Success (201), Validation error (422) | | `/v3/checkout/{id}` | GET | All status variants | | `/v3/balance` | GET | Success with balances (200) | | `/v3/collection_account` | GET | Success with accounts (200) | #### Mock Response Examples ```typescript // mock-server/fixtures/entity-responses.ts export const entityApproved = { status: 'success', data: { id: 'ent_test_123', name: 'Test Company', type: 'company', approval_status: 'approved', created_at: '2024-01-01T00:00:00Z', }, }; export const entityPending = { status: 'success', data: { id: 'ent_test_456', approval_status: 'pending', pending_requirements: ['business_registration', 'id_document'], }, }; export const payoutSucceeded = { id: 'pot_test_789', object: 'payout', amount: 10000, currency: 'USD', status: 'succeeded', beneficiary: 'bnf_test_001', tracking_details: { tracking_number: 'UETR123456', tracking_type: 'uetr', }, created_at: '2024-01-01T00:00:00Z', }; ``` ### 7. Plugin Test Scenarios #### Unit Tests per Handler **tazapay-create-entity.ts** - [ ] Creates entity when CustomerProvider doesn't exist - [ ] Returns existing entity when CustomerProvider has entityId and status is approved - [ ] Throws error when existing entity is rejected - [ ] Handles API timeout with retry - [ ] Handles duplicate entity error gracefully - [ ] Stores entityId in intent.metadata and CustomerProvider **tazapay-verify-entity.ts** - [ ] Returns success when entity is approved - [ ] Throws error when entity is pending (ACTION_REQUIRED) - [ ] Throws error when entity is rejected with reason - [ ] Throws error when entity is still under review - [ ] Updates CustomerProvider.onboardingData on status change **webhook-module.ts (authenticate)** - [ ] Returns intentId for valid signature - [ ] Returns null for invalid signature - [ ] Returns null for missing signature header - [ ] Returns null for expired timestamp (>10 min) - [ ] Returns null when intent not found **webhook-module.ts (handle)** - [ ] Updates MoneyMovementTransaction to SUCCESS on payout.succeeded - [ ] Updates MoneyMovementTransaction to FAILED on payout.failed - [ ] Updates CustomerProvider.onboardingData on entity.approved - [ ] Handles collect.succeeded and triggers payout - [ ] Skips duplicate events (idempotent) **transaction.ts (offramp)** - [ ] Creates beneficiary if not exists - [ ] Reuses existing beneficiary from intent.metadata - [ ] Creates quote and payout - [ ] Returns crypto deposit instructions - [ ] Handles quote expiry by creating new quote - [ ] Handles insufficient balance gracefully - [ ] Idempotent: returns existing payout if already created **transaction.ts (onramp)** - [ ] Creates checkout session - [ ] Returns hosted checkout URL - [ ] Handles checkout expiry - [ ] Idempotent: returns existing checkout if already created #### Integration Tests for Full Flows **Offramp Flow (USDC -> USD bank)** 1. Create intent with crypto source, bank destination 2. Run compliance flow (entity creation, verification) 3. Execute createTransaction 4. Verify deposit instructions returned 5. Simulate collect.succeeded webhook 6. Simulate payout.succeeded webhook 7. Verify MoneyMovementTransaction status is SUCCESS **Onramp Flow (USD card -> USDC wallet)** 1. Create intent with fiat source, crypto destination 2. Run compliance flow (customer creation) 3. Execute createTransaction 4. Verify checkout URL returned 5. Simulate payin.succeeded webhook 6. Verify wallet payout triggered 7. Simulate payout.succeeded webhook 8. Verify MoneyMovementTransaction status is SUCCESS **KYB Rejection Flow** 1. Create intent 2. Run compliance flow 3. Simulate entity.rejected webhook 4. Verify step marked as FAILED 5. Verify CustomerProvider.onboardingData updated with rejection reason --- ## === IMPLEMENTATION ORDER === ### 8. Dependency Graph ``` ┌──────────────────┐ │ types.ts │ │ interfaces.ts │ └────────┬─────────┘ │ ┌────────▼─────────┐ │ config.ts │ │credential.schema │ └────────┬─────────┘ │ ┌────────▼─────────┐ │ tazapay.api. │ │ service.ts │ └────────┬─────────┘ │ ┌──────────────┼──────────────┐ │ │ │ ┌────────▼───────┐ ┌────▼────┐ ┌───────▼───────┐ │ handlers/ │ │manifest │ │ webhook- │ │ *.ts │ │ .ts │ │ module.ts │ └────────┬───────┘ └────┬────┘ └───────┬───────┘ │ │ │ └──────────────┼──────────────┘ │ ┌────────▼─────────┐ │ transaction.ts │ │ money-movement/ │ └────────┬─────────┘ │ ┌────────▼─────────┐ │ dashboard/ │ │ components │ └────────┬─────────┘ │ ┌────────▼─────────┐ │ index.ts │ │ (main export) │ └──────────────────┘ ``` ### Implementation Phases #### Phase 1: Foundation (client layer) 1. `client/interfaces.ts` - All TypeScript types 2. `client/config.ts` - Constants, base URLs 3. `client/credential.schema.ts` - Credential form 4. `client/tazapay.api.service.ts` - API client #### Phase 2: Compliance (server handlers) 5. `api-server/types.ts` - Server-side types 6. `api-server/handlers/index.ts` - Handler infrastructure 7. `api-server/handlers/tazapay-create-entity.ts` 8. `api-server/handlers/tazapay-verify-entity.ts` 9. `api-server/handlers/tazapay-create-customer.ts` 10. `api-server/handlers/tazapay-create-beneficiary.ts` #### Phase 3: Webhooks 11. `api-server/webhook-module.ts` - Full webhook handling #### Phase 4: Money Movement 12. `money-movement/index.ts` - Capability matching 13. `money-movement/transaction.ts` - Transaction execution #### Phase 5: Integration 14. `manifest.ts` - Plugin manifest 15. `index.ts` - Main exports #### Phase 6: Dashboard (optional, can be deferred) 16. `dashboard/handlers/index.ts` 17. `dashboard/handlers/TazapayOnboarding.tsx` 18. `dashboard/handlers/tazapay-onboarding.ts` 19. `dashboard/index.ts` #### Phase 7: Testing 20. Mock server setup 21. Unit tests 22. Integration tests --- ## Appendix: Currency Pairs to Implement Based on USER_INPUT.md, the following currency pairs will be implemented: ### Onramp (Fiat -> Crypto) | Source (Fiat) | Destination (Crypto) | Transfer Type | |---------------|---------------------|---------------| | USD | USDC_ETH, USDC_POLYGON, USDC_TRON, USDC_SOLANA | Card, ACH | | USD | USDT_ETH, USDT_POLYGON, USDT_TRON, USDT_SOLANA | Card, ACH | | SGD | USDC_*, USDT_* | Card, PayNow | | EUR | USDC_*, USDT_* | Card, SEPA | | INR | USDC_*, USDT_* | Card, UPI, IMPS | | BRL | USDC_*, USDT_* | PIX | | PHP | USDC_*, USDT_* | InstaPay | | THB | USDC_*, USDT_* | PromptPay | | MXN | USDC_*, USDT_* | SPEI | | VND | USDC_*, USDT_* | Bank push | ### Offramp (Crypto -> Fiat) | Source (Crypto) | Destination (Fiat) | Transfer Type | |-----------------|-------------------|---------------| | USDC_ETH, USDC_POLYGON, USDC_TRON, USDC_SOLANA | USD | ACH, SWIFT | | USDT_ETH, USDT_POLYGON, USDT_TRON, USDT_SOLANA | USD | ACH, SWIFT | | USDC_*, USDT_* | SGD | FAST, SWIFT | | USDC_*, USDT_* | EUR | SEPA, SWIFT | | USDC_*, USDT_* | INR | RTGS, IMPS | | USDC_*, USDT_* | BRL | PIX | | USDC_*, USDT_* | PHP | InstaPay, PESONet | | USDC_*, USDT_* | THB | PromptPay | | USDC_*, USDT_* | MXN | SPEI | | USDC_*, USDT_* | VND | NAPAS | Total: ~80 currency pair combinations (9 fiat x 8 crypto + 8 crypto x 9 fiat) --- PHASE_3_COMPLETE