mirror of
https://github.com/hanzoai/idv.git
synced 2026-10-30 18:33:39 +00:00
234 lines
6.4 KiB
Go
234 lines
6.4 KiB
Go
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
|
// Use of this source code is governed by the Apache 2.0
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
PlaidProduction = "https://production.plaid.com"
|
|
PlaidSandbox = "https://sandbox.plaid.com"
|
|
)
|
|
|
|
// PlaidConfig holds Plaid API credentials.
|
|
type PlaidConfig struct {
|
|
BaseURL string
|
|
ClientID string
|
|
Secret string
|
|
WebhookURL string // callback URL for identity verification events
|
|
}
|
|
|
|
// Plaid implements the Provider interface for Plaid Identity Verification.
|
|
type Plaid struct {
|
|
cfg PlaidConfig
|
|
client *http.Client
|
|
}
|
|
|
|
// NewPlaid creates a Plaid Identity IDV provider.
|
|
func NewPlaid(cfg PlaidConfig) *Plaid {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = PlaidSandbox
|
|
}
|
|
return &Plaid{
|
|
cfg: cfg,
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *Plaid) Name() string { return ProviderPlaid }
|
|
|
|
// InitiateVerification creates a Plaid Identity Verification session.
|
|
func (p *Plaid) InitiateVerification(ctx context.Context, req *VerificationRequest) (*VerificationResponse, error) {
|
|
payload := map[string]interface{}{
|
|
"client_id": p.cfg.ClientID,
|
|
"secret": p.cfg.Secret,
|
|
"is_shareable": true,
|
|
"template_id": req.Workflow,
|
|
"gave_consent": true,
|
|
"user": map[string]interface{}{
|
|
"client_user_id": req.ApplicationID,
|
|
"email_address": req.Email,
|
|
"name": map[string]string{
|
|
"given_name": req.GivenName,
|
|
"family_name": req.FamilyName,
|
|
},
|
|
"date_of_birth": req.DateOfBirth,
|
|
},
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
p.cfg.BaseURL+"/identity_verification/create", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plaid identity verification create: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
errBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("plaid API error %d: %s", resp.StatusCode, string(errBody))
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
ShareableURL string `json:"shareable_url"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("plaid response decode: %w", err)
|
|
}
|
|
|
|
return &VerificationResponse{
|
|
VerificationID: result.ID,
|
|
Provider: ProviderPlaid,
|
|
Status: StatusPending,
|
|
RedirectURL: result.ShareableURL,
|
|
CreatedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
// CheckStatus retrieves the current status of a Plaid identity verification.
|
|
func (p *Plaid) CheckStatus(ctx context.Context, verificationID string) (*VerificationStatusResult, error) {
|
|
payload := map[string]string{
|
|
"client_id": p.cfg.ClientID,
|
|
"secret": p.cfg.Secret,
|
|
"identity_verification_id": verificationID,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
p.cfg.BaseURL+"/identity_verification/get", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plaid status check failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
errBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("plaid status check error %d: %s", resp.StatusCode, string(errBody))
|
|
}
|
|
|
|
var result struct {
|
|
Status string `json:"status"`
|
|
Steps struct {
|
|
VerifySMS string `json:"verify_sms"`
|
|
DocumentaryVerification string `json:"documentary_verification"`
|
|
SelfieCheck string `json:"selfie_check"`
|
|
KYCCheck string `json:"kyc_check"`
|
|
RiskCheck string `json:"risk_check"`
|
|
} `json:"steps"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("plaid status decode: %w", err)
|
|
}
|
|
|
|
status := mapPlaidStatus(result.Status)
|
|
checks := []Check{
|
|
{Type: "documentary_verification", Status: result.Steps.DocumentaryVerification},
|
|
{Type: "selfie_check", Status: result.Steps.SelfieCheck},
|
|
{Type: "kyc_check", Status: result.Steps.KYCCheck},
|
|
{Type: "risk_check", Status: result.Steps.RiskCheck},
|
|
}
|
|
|
|
return &VerificationStatusResult{
|
|
VerificationID: verificationID,
|
|
Provider: ProviderPlaid,
|
|
Status: status,
|
|
Checks: checks,
|
|
}, nil
|
|
}
|
|
|
|
// ParseWebhook parses a Plaid Identity Verification webhook.
|
|
func (p *Plaid) ParseWebhook(body []byte, headers map[string]string) (*WebhookEvent, error) {
|
|
var payload struct {
|
|
WebhookType string `json:"webhook_type"`
|
|
WebhookCode string `json:"webhook_code"`
|
|
IdentityVerificationID string `json:"identity_verification_id"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("plaid webhook decode: %w", err)
|
|
}
|
|
|
|
var status VerificationStatus
|
|
switch payload.WebhookCode {
|
|
case "STEP_COMPLETED":
|
|
status = StatusPending
|
|
case "VERIFICATION_COMPLETED":
|
|
result, err := p.getVerificationResult(payload.IdentityVerificationID)
|
|
if err != nil {
|
|
status = StatusError
|
|
} else {
|
|
status = result
|
|
}
|
|
case "VERIFICATION_EXPIRED":
|
|
status = StatusExpired
|
|
default:
|
|
status = StatusPending
|
|
}
|
|
|
|
return &WebhookEvent{
|
|
Provider: ProviderPlaid,
|
|
VerificationID: payload.IdentityVerificationID,
|
|
Status: status,
|
|
RawPayload: body,
|
|
ReceivedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plaid) getVerificationResult(verificationID string) (VerificationStatus, error) {
|
|
payload := map[string]string{
|
|
"client_id": p.cfg.ClientID,
|
|
"secret": p.cfg.Secret,
|
|
"identity_verification_id": verificationID,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
p.cfg.BaseURL+"/identity_verification/get", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return StatusError, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Status string `json:"status"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
return mapPlaidStatus(result.Status), nil
|
|
}
|
|
|
|
func mapPlaidStatus(s string) VerificationStatus {
|
|
switch s {
|
|
case "success":
|
|
return StatusApproved
|
|
case "failed":
|
|
return StatusDeclined
|
|
case "expired":
|
|
return StatusExpired
|
|
default:
|
|
return StatusPending
|
|
}
|
|
}
|