> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fluz.app/llms.txt
> Use this file to discover all available pages before exploring further.

# 透過 Plaid 連結銀行帳戶

使用以下作業讓使用者透過 **Plaid Link** 將銀行帳戶連結至 Fluz。連結是所有銀行帳戶資金加值的進入點：一旦帳戶連結後，您可以讀取其餘額與可用消費力、拉取其交易歷史、以及在連線日後中斷時進行修復——每項功能皆有各自的頁面說明。

Transactional Graph Service (TGS) 針對 Fluz 的 Plaid 整合提供公開的 GraphQL 封裝。Identity-service 仍是 Plaid 權杖、銀行帳戶匯入、餘額、歷史交易與 Plaid webhook 的權威系統。TGS 絕不會將 Plaid access tokens 或 public tokens 回傳給您的應用。

## Auth

以包含 `MANAGE_PAYMENT` 權限範圍的 Fluz **使用者 Bearer 存取權杖** 呼叫 `/api/v1/graphql`。

Plaid 欄位**不**允許使用 Basic auth。請先透過標準的 TGS 認證流程取得使用者存取權杖，接著以如下方式呼叫這些欄位：

```http theme={null}
Authorization: Bearer <fluz-user-access-token>
```

## 鳥瞰連結流程

1. 建立 Link token（`createPlaidLinkToken`）。
2. 使用該 token 開啟 Plaid Link。
3. 在 Plaid 的 `onSuccess` 中，將回傳的 `public_token` 傳回 TGS（`completePlaidLink`）。
4. 儲存完成回應中的 `platformItemId`——這是之後用於重新連結的安全公開識別碼。

### 1. 建立 Link token

```graphql theme={null}
mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
  createPlaidLinkToken(input: $input) {
    linkToken
    expiration
    requestId
    mode
  }
}
```

```json theme={null}
{
  "input": {}
}
```

對於**原生** Link，請傳入 `deviceOs` 為 `IOS` 或 `ANDROID`，使 identity-service 加入正確的 Plaid OAuth 重新導向選項。

```json theme={null}
{
  "input": {
    "deviceOs": "IOS"
  }
}
```

### 2. 開啟 Plaid Link

以回傳的 `linkToken` 初始化 Plaid Link。（見下方 Web SDK 範例。）

### 3. 完成連結

在 Plaid Link 的 `onSuccess` 中，將回傳的 `public_token` 傳送至 TGS。

```graphql theme={null}
mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
  completePlaidLink(input: $input) {
    requiresAddress
    bankAccountId
    bankInstitutionAuthId
    newlyLinkedBankInstitutionAuthId
    bankInstitutionName
    platformItemId
    bankAccounts {
      bankInstitutionAuthId
      bankAccountId
      bankName
      lastFour
      type
      subtype
    }
  }
}
```

```json theme={null}
{
  "input": {
    "publicToken": "public-sandbox-..."
  }
}
```

### 4. 儲存 `platformItemId`

將完成回應中的 `platformItemId` 存起來。這是之後用於重新連結已中斷連線的安全公開識別碼。若回應未提供，請呼叫該使用者的 `getPlaidBankAccounts`，並儲存該回應中已持久化的 `platformItemId`。

若 `completePlaidLink` 回傳 `requiresAddress: true`，請在帳戶可用前先附加地址——見下方的「附加地址」。

## Plaid Web SDK 範例

Plaid 的 Web SDK script 必須直接自 Plaid 的 CDN 載入。

```html theme={null}
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
```

透過 TGS 建立 Link token，以該 token 初始化 Plaid Link，並在 `onSuccess` 中將 Plaid 的 `public_token` 傳回 TGS。以不帶參數呼叫 `startPlaidLink()` 會開始**新的**連結；以 `startPlaidLink(existingPlatformItemId)` 會開始**重新連結**（見「*重新連結中斷的銀行帳戶*」）。

```javascript theme={null}
const FLUZ_GRAPHQL_URL = '<fluz-api-base-url>/api/v1/graphql';
const fluzUserAccessToken = '<fluz-user-access-token>';

const fluzGraphql = async (query, variables) => {
  const response = await fetch(FLUZ_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${fluzUserAccessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  });

  const body = await response.json();
  if (!response.ok || body.errors?.length) {
    throw new Error(body.errors?.[0]?.message ?? 'Fluz GraphQL request failed');
  }

  return body.data;
};

const createPlaidLinkToken = async (platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
        createPlaidLinkToken(input: $input) {
          linkToken
          mode
        }
      }
    `,
    { input: { platformItemId } },
  );

  return data.createPlaidLinkToken;
};

const completePlaidLink = async (publicToken, platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
        completePlaidLink(input: $input) {
          requiresAddress
          bankInstitutionAuthId
          newlyLinkedBankInstitutionAuthId
          bankAccountId
          platformItemId
          bankAccounts {
            bankInstitutionAuthId
            bankAccountId
            bankName
            lastFour
            type
            subtype
          }
        }
      }
    `,
    { input: { publicToken, platformItemId } },
  );

  return data.completePlaidLink;
};

const getPlaidBankAccounts = async (input) => {
  const data = await fluzGraphql(
    `
      query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
        getPlaidBankAccounts(input: $input) {
          platformItemId
          bankInstitutionAuthId
          bankAccountId
          bankInstitutionName
          lastFour
          type
          subtype
        }
      }
    `,
    { input },
  );

  return data.getPlaidBankAccounts;
};

const resolvePlatformItemId = async (completion) => {
  if (completion.platformItemId) return completion.platformItemId;

  const bankInstitutionAuthId =
    completion.bankInstitutionAuthId ?? completion.newlyLinkedBankInstitutionAuthId;
  const accounts = await getPlaidBankAccounts({ bankInstitutionAuthId });
  return accounts[0]?.platformItemId;
};

const startPlaidLink = async (platformItemId) => {
  const { linkToken } = await createPlaidLinkToken(platformItemId);

  const handler = window.Plaid.create({
    token: linkToken,
    onSuccess: async (publicToken, metadata) => {
      const result = await completePlaidLink(publicToken, platformItemId);
      const persistedPlatformItemId = await resolvePlatformItemId(result);

      await saveConnectionForRelinkLater({
        platformItemId: persistedPlatformItemId,
        bankInstitutionAuthId:
          result.bankInstitutionAuthId ?? result.newlyLinkedBankInstitutionAuthId,
        bankAccounts: result.bankAccounts,
        plaidLinkSessionId: metadata.link_session_id,
      });
    },
    onExit: (error, metadata) => {
      console.log('Plaid Link exited', { error, metadata });
    },
  });

  handler.open();
};
```

## 列出已連結帳戶

回傳已驗證使用者的安全 Plaid 銀行帳戶。

```graphql theme={null}
query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
  getPlaidBankAccounts(input: $input) {
    platformItemId
    bankInstitutionAuthId
    bankInstitutionName
    bankAccountId
    accountName
    lastFour
    type
    subtype
    status
  }
}
```

```json theme={null}
{
  "input": {
    "platformItemId": "plaid-item-id"
  }
}
```

## 附加地址

如果 `completePlaidLink` 回傳 `requiresAddress: true`，請為已連結的銀行機構附加地址。

```graphql theme={null}
mutation CreatePlaidLinkAddress($input: CreatePlaidLinkAddressInput!) {
  createPlaidLinkAddress(input: $input) {
    addressId
  }
}
```

```json theme={null}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "address": {
      "streetAddressLine1": "123 Main St",
      "streetAddressLine2": "Apt 4",
      "city": "New York",
      "state": "NY",
      "postalCode": "10001",
      "country": "US"
    }
  }
}
```

## 移除銀行連線

從使用者帳戶中移除某個 Plaid 銀行機構。

```graphql theme={null}
mutation RemovePlaidBankInstitution($input: RemovePlaidBankInstitutionInput!) {
  removePlaidBankInstitution(input: $input) {
    removed
    bankInstitutionAuthId
  }
}
```

```json theme={null}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "reason": "USER_REQUESTED"
  }
}
```

## 安全邊界

TGS 不會回傳 Plaid access tokens、Plaid public tokens、身分比對細節、銀行帳號或匯款路由號碼。銀行帳戶名稱與暱稱可能是使用者輸入的銀行標籤，僅作為帳戶中繼資料回傳。

Identity-service 仍負責：

* Plaid 權杖交換
* Plaid access token 儲存
* 銀行機構與銀行帳戶的建立或修復
* 餘額擷取
* 歷史交易匯入
* 銀行機構移除
* Plaid webhooks

TGS 不會直接暴露 identity-service，亦不會新增公開的 Plaid webhook 路由。

## 不支援的流程

手動 Plaid 微額存款連結在此公開封裝中刻意不支援。新的連結與重新連結必須使用一般的 Plaid Link 流程，該流程支援餘額與歷史交易。

若使用者為同一機構啟動了新連結而非選擇重新連結，請將其完成為一般的新連結。Identity-service 負責銀行帳戶去重與修復；在完成後儲存回傳的 `platformItemId`。

## 錯誤處理

若 `completePlaidLink` 在 Plaid 已回傳 `public_token` 後逾時或失敗，請在重試前，先以已儲存或預期的 `platformItemId` 呼叫 `getPlaidBankAccounts`。僅在 Plaid 於新的 Link 工作階段回傳新的 `public_token` 時才重試——Plaid public tokens 具短效且一次性使用的特性。

<br />
