# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 3143/dev/payletter-terminal-notification.spec.ts >> [3143][dev] [QA] 페이레터 KoVan 단말기 승인/취소 통보 화면 반영 검증 >> [ADMIN] role >> 승인 통보 전송 후 거래 내역 화면에서 승인 거래가 조회되는지 확인
- Location: src/qa/scenarios/payletterTerminalNotification.ts:100:7

# Error details

```
Error: 거래 상태가 승인/결제완료 계열이어야 합니다. 실제=CANCEL / 12026-05-26 15:36:002026-05-26 11:51:13페이레터A_주홍석_법인_페이레터A_주홍석_법인A_주홍석_가맹5_페이레터BP10051610.0001,004-100-903일반정산매 5분지갑정산내역비신고취소000002일시불테스트카드244112******9428-결제통보-인증2026-05-26 15:35:00----0--취소내역000001원거래 아님BP100516A_주홍석_가맹5_페이레터paylettersecret-redacted000000997981 / CANCEL

expect(received).toBe(expected) // Object.is equality

Expected: true
Received: false
```

# Test source

```ts
  651 |   const candidates = items.filter((item) => matchesTargetKeys(item, config));
  652 |   return candidates.find((item) => matchesExpectedStatus(item, expectedStatus)) ?? candidates[0];
  653 | }
  654 | 
  655 | function matchesTargetKeys(item: AdminTransactionItem, config: NotificationConfig): boolean {
  656 |   const serialized = JSON.stringify(item);
  657 |   const hasIdentifier = [
  658 |     config.payload.tid,
  659 |     config.payload.cid,
  660 |     config.payload.order_no,
  661 |     config.payload.pltid,
  662 |     config.payload.cat_id
  663 |   ].some((value) => value && serialized.includes(value));
  664 |   return hasIdentifier && extractAmount(item) === config.amount;
  665 | }
  666 | 
  667 | function hasAnyIdentifier(value: string, config: NotificationConfig): boolean {
  668 |   return [
  669 |     config.payload.tid,
  670 |     config.payload.cid,
  671 |     config.payload.order_no,
  672 |     config.payload.pltid,
  673 |     config.payload.cat_id
  674 |   ].some((identifier) => identifier && value.includes(identifier));
  675 | }
  676 | 
  677 | function matchesExpectedStatus(item: AdminTransactionItem, expectedStatus: "approval" | "cancel"): boolean {
  678 |   const statusText = extractStatusText(item);
  679 |   return expectedStatus === "approval" ? isApprovalStatus(statusText) : isCancelStatus(statusText);
  680 | }
  681 | 
  682 | function extractStatusText(item: AdminTransactionItem): string {
  683 |   const statusValues = collectStringValues(item).filter((value) => /PAYMENT|APPROV|PAID|CANCEL|취소|승인|결제|완료/i.test(value));
  684 |   return [item.transaction?.status, ...statusValues].filter(Boolean).join(" / ");
  685 | }
  686 | 
  687 | function extractAmount(item: AdminTransactionItem): number {
  688 |   const values: number[] = [];
  689 |   collectNumericValues(item, values);
  690 |   const exact = values.find((value) => Math.abs(value) === 1004);
  691 |   if (exact !== undefined) {
  692 |     return Math.abs(exact);
  693 |   }
  694 |   const transactionAmount = Number(item.transaction?.amount);
  695 |   return Number.isFinite(transactionAmount) ? Math.abs(transactionAmount) : 0;
  696 | }
  697 | 
  698 | function extractDateText(item: AdminTransactionItem, expectedDateTime: string): string {
  699 |   const strings = collectStringValues(item);
  700 |   return strings.find((value) => containsDateTime(value, expectedDateTime)) ?? item.transaction?.payDate ?? item.transaction?.createdAt ?? "";
  701 | }
  702 | 
  703 | function extractEvidenceText(item: AdminTransactionItem): string {
  704 |   const strings = collectStringValues(item);
  705 |   if (strings.length > 0) {
  706 |     return strings.join(" ");
  707 |   }
  708 |   return JSON.stringify(item);
  709 | }
  710 | 
  711 | function collectStringValues(value: unknown): string[] {
  712 |   if (typeof value === "string") {
  713 |     return [value];
  714 |   }
  715 |   if (!value || typeof value !== "object") {
  716 |     return [];
  717 |   }
  718 |   if (Array.isArray(value)) {
  719 |     return value.flatMap((item) => collectStringValues(item));
  720 |   }
  721 |   return Object.values(value as Record<string, unknown>).flatMap((item) => collectStringValues(item));
  722 | }
  723 | 
  724 | function collectNumericValues(value: unknown, output: number[]): void {
  725 |   if (typeof value === "number" && Number.isFinite(value)) {
  726 |     output.push(value);
  727 |     return;
  728 |   }
  729 |   if (!value || typeof value !== "object") {
  730 |     return;
  731 |   }
  732 |   for (const nested of Object.values(value as Record<string, unknown>)) {
  733 |     collectNumericValues(nested, output);
  734 |   }
  735 | }
  736 | 
  737 | function uniqueObjects(items: AdminTransactionItem[]): AdminTransactionItem[] {
  738 |   const seen = new Set<string>();
  739 |   return items.filter((item) => {
  740 |     const key = item.transaction?.id ?? item.transaction?.approvalNo ?? JSON.stringify(item);
  741 |     if (seen.has(key)) {
  742 |       return false;
  743 |     }
  744 |     seen.add(key);
  745 |     return true;
  746 |   });
  747 | }
  748 | 
  749 | function expectStatus(target: LocatedTransaction, expectedStatus: "approval" | "cancel"): void {
  750 |   if (expectedStatus === "approval") {
> 751 |     expect(isApprovalStatus(target.statusText), `거래 상태가 승인/결제완료 계열이어야 합니다. 실제=${target.statusText}`).toBe(true);
      |                                                                                                      ^ Error: 거래 상태가 승인/결제완료 계열이어야 합니다. 실제=CANCEL / 12026-05-26 15:36:002026-05-26 11:51:13페이레터A_주홍석_법인_페이레터A_주홍석_법인A_주홍석_가맹5_페이레터BP10051610.0001,004-100-903일반정산매 5분지갑정산내역비신고취소000002일시불테스트카드244112******9428-결제통보-인증2026-05-26 15:35:00----0--취소내역000001원거래 아님BP100516A_주홍석_가맹5_페이레터paylettersecret-redacted000000997981 / CANCEL
  752 |     return;
  753 |   }
  754 |   expect(isCancelStatus(target.statusText), `거래 상태가 취소 계열이어야 합니다. 실제=${target.statusText}`).toBe(true);
  755 | }
  756 | 
  757 | function expectScreenContainsStatus(target: LocatedTransaction, expectedStatus: "approval" | "cancel"): void {
  758 |   const pattern = expectedStatus === "approval" ? /승인|결제\s*완료|결제완료|완료/ : /취소|취소\s*완료|취소완료/;
  759 |   expect(pattern.test(target.bodyText), `거래내역 화면에 ${expectedStatus === "approval" ? "승인" : "취소"} 상태 문구가 보여야 합니다.`).toBe(true);
  760 | }
  761 | 
  762 | function expectDateTime(target: LocatedTransaction, expectedDateTime: string): void {
  763 |   expect(
  764 |     containsDateTime(target.dateText, expectedDateTime) || containsDateTime(JSON.stringify(target.item), expectedDateTime),
  765 |     `거래일시는 ${expectedDateTime}이어야 합니다. 실제=${target.dateText}`
  766 |   ).toBe(true);
  767 | }
  768 | 
  769 | function isApprovalStatus(value: string): boolean {
  770 |   return /PAYMENT|APPROV|PAID|승인|결제\s*완료|결제완료/i.test(value) && !isCancelStatus(value);
  771 | }
  772 | 
  773 | function isCancelStatus(value: string): boolean {
  774 |   return /CANCEL|CANCELED|CANCELLED|취소/i.test(value);
  775 | }
  776 | 
  777 | function inferStatusFromText(value: string): string {
  778 |   if (isCancelStatus(value)) {
  779 |     return "CANCEL";
  780 |   }
  781 |   if (isApprovalStatus(value)) {
  782 |     return "PAYMENT";
  783 |   }
  784 |   return value;
  785 | }
  786 | 
  787 | function containsAmount(bodyText: string, amount: number): boolean {
  788 |   const formatted = formatNumber(amount);
  789 |   return bodyText.includes(formatted) || bodyText.includes(String(amount));
  790 | }
  791 | 
  792 | function containsCardInfo(target: LocatedTransaction, config: NotificationConfig): boolean {
  793 |   return target.bodyText.includes(config.expectedCardInfo) && target.bodyText.includes(config.expectedCardNo);
  794 | }
  795 | 
  796 | function containsDateTime(value: string, expectedDateTime: string): boolean {
  797 |   const normalizedValue = value.replace(/[./]/g, "-").replace("T", " ");
  798 |   const withoutSeconds = expectedDateTime.slice(0, 16);
  799 |   const compact = expectedDateTime.replace(/\D/g, "");
  800 |   return normalizedValue.includes(expectedDateTime) || normalizedValue.includes(withoutSeconds) || value.replace(/\D/g, "").includes(compact);
  801 | }
  802 | 
  803 | async function getApprovedTransaction(
  804 |   browser: Browser,
  805 |   runtimeEnv: RuntimeQaEnv,
  806 |   pageDefinition: QaPageDefinition,
  807 |   config: NotificationConfig,
  808 |   testInfo: TestInfo
  809 | ): Promise<LocatedTransaction> {
  810 |   runState.approved ??= await locateTransaction(browser, runtimeEnv, pageDefinition, config, "approval", testInfo, {
  811 |     suffix: "approval-visible"
  812 |   });
  813 |   return runState.approved;
  814 | }
  815 | 
  816 | async function getCanceledTransaction(
  817 |   browser: Browser,
  818 |   runtimeEnv: RuntimeQaEnv,
  819 |   pageDefinition: QaPageDefinition,
  820 |   config: NotificationConfig,
  821 |   testInfo: TestInfo
  822 | ): Promise<LocatedTransaction> {
  823 |   runState.canceled ??= await locateTransaction(browser, runtimeEnv, pageDefinition, config, "cancel", testInfo, {
  824 |     suffix: "cancel-visible"
  825 |   });
  826 |   return runState.canceled;
  827 | }
  828 | 
  829 | async function attachEvidenceLog(
  830 |   testInfo: TestInfo,
  831 |   pageName: string,
  832 |   role: string,
  833 |   suffix: string,
  834 |   lines: string[]
  835 | ): Promise<void> {
  836 |   await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.md`, {
  837 |     body: lines.join("\n"),
  838 |     contentType: "text/markdown"
  839 |   });
  840 | }
  841 | 
  842 | async function attachPageScreenshot(
  843 |   page: Page,
  844 |   testInfo: TestInfo,
  845 |   pageName: string,
  846 |   role: string,
  847 |   suffix: string
  848 | ): Promise<void> {
  849 |   const screenshotPath = testInfo.outputPath(`${safeFilename(pageName)}-${role}-${suffix}.png`);
  850 |   await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10_000 });
  851 |   await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.png`, {
```