# 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