diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index bcc4816..5b02fac 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -33,6 +33,17 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java
new file mode 100644
index 0000000..41bcdce
--- /dev/null
+++ b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java
@@ -0,0 +1,86 @@
+package kr.sblog.slimbudget;
+
+import android.app.Notification;
+import android.os.Bundle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+
+import org.json.JSONObject;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 카드사 결제 푸시 알림을 가로채 백엔드로 전달한다.
+ * - 결제로 보이는 알림(금액+승인/결제/카드)만 전송 (개인정보 최소화)
+ * - 인증 토큰은 Capacitor Preferences(SharedPreferences "CapacitorStorage")의 token 사용
+ * - 백엔드가 파싱·중복제거·미확인 내역 생성
+ */
+public class CardNotifListenerService extends NotificationListenerService {
+
+ private static final String API_URL = "https://app.sblog.kr/api/account/entries/notification";
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn) {
+ try {
+ if (sbn == null || sbn.getNotification() == null) return;
+ Bundle ex = sbn.getNotification().extras;
+ if (ex == null) return;
+
+ CharSequence titleCs = ex.getCharSequence(Notification.EXTRA_TITLE);
+ CharSequence textCs = ex.getCharSequence(Notification.EXTRA_TEXT);
+ CharSequence bigCs = ex.getCharSequence(Notification.EXTRA_BIG_TEXT);
+
+ String title = titleCs == null ? "" : titleCs.toString();
+ String text = (bigCs != null ? bigCs : (textCs == null ? "" : textCs)).toString();
+
+ if (!looksLikePayment(title + " " + text)) return;
+
+ String token = getSharedPreferences("CapacitorStorage", MODE_PRIVATE)
+ .getString("token", null);
+ if (token == null || token.isEmpty()) return;
+
+ final String pkg = sbn.getPackageName();
+ new Thread(() -> post(pkg, title, text, token)).start();
+ } catch (Exception ignore) {
+ // 알림 처리 실패는 무시 (앱 동작에 영향 없도록)
+ }
+ }
+
+ /** 결제 알림 후보 판별: 금액(####원) + 승인/결제/카드 키워드 */
+ private boolean looksLikePayment(String s) {
+ boolean money = s.matches("(?s).*\\d[\\d,]{2,}\\s*원.*");
+ boolean kw = s.contains("승인") || s.contains("결제") || s.contains("카드");
+ return money && kw;
+ }
+
+ private void post(String pkg, String title, String text, String token) {
+ HttpURLConnection conn = null;
+ try {
+ URL url = new URL(API_URL);
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setRequestProperty("Authorization", "Bearer " + token);
+ conn.setConnectTimeout(8000);
+ conn.setReadTimeout(8000);
+ conn.setDoOutput(true);
+
+ JSONObject body = new JSONObject();
+ body.put("app", pkg);
+ body.put("title", title);
+ body.put("text", text);
+
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(body.toString().getBytes(StandardCharsets.UTF_8));
+ }
+ conn.getResponseCode(); // 요청 전송 (응답은 무시)
+ } catch (Exception ignore) {
+ // 네트워크 실패는 무시
+ } finally {
+ if (conn != null) conn.disconnect();
+ }
+ }
+}
diff --git a/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java
new file mode 100644
index 0000000..f5cc534
--- /dev/null
+++ b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java
@@ -0,0 +1,39 @@
+package kr.sblog.slimbudget;
+
+import android.content.Intent;
+import android.provider.Settings;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+/**
+ * 카드 결제 알림 자동인식 플러그인.
+ * - isEnabled: 알림 접근 권한(NotificationListener) 허용 여부
+ * - openSettings: 알림 접근 설정 화면 열기
+ * 실제 알림 수신/전송은 CardNotifListenerService 가 담당한다.
+ */
+@CapacitorPlugin(name = "CardNotif")
+public class CardNotifPlugin extends Plugin {
+
+ @PluginMethod
+ public void isEnabled(PluginCall call) {
+ String pkg = getContext().getPackageName();
+ String flat = Settings.Secure.getString(
+ getContext().getContentResolver(), "enabled_notification_listeners");
+ boolean enabled = flat != null && flat.contains(pkg);
+ JSObject ret = new JSObject();
+ ret.put("enabled", enabled);
+ call.resolve(ret);
+ }
+
+ @PluginMethod
+ public void openSettings(PluginCall call) {
+ Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ getContext().startActivity(intent);
+ call.resolve();
+ }
+}
diff --git a/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java b/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java
index 78a4bd5..5a66b49 100644
--- a/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java
+++ b/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java
@@ -1,5 +1,13 @@
package kr.sblog.slimbudget;
+import android.os.Bundle;
+
import com.getcapacitor.BridgeActivity;
-public class MainActivity extends BridgeActivity {}
+public class MainActivity extends BridgeActivity {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ registerPlugin(CardNotifPlugin.class);
+ super.onCreate(savedInstanceState);
+ }
+}
diff --git a/src/native/cardNotif.js b/src/native/cardNotif.js
new file mode 100644
index 0000000..6f65e57
--- /dev/null
+++ b/src/native/cardNotif.js
@@ -0,0 +1,28 @@
+// 카드 결제 알림 자동인식 — 네이티브 플러그인(CardNotif) 래퍼.
+// 웹에서는 no-op. 권한 확인/설정 열기만 제공(실제 수신·전송은 네이티브 서비스가 담당).
+import { Capacitor, registerPlugin } from '@capacitor/core'
+
+const Plugin = registerPlugin('CardNotif')
+
+export const cardNotif = {
+ isNative() {
+ return Capacitor.isNativePlatform()
+ },
+ async isEnabled() {
+ if (!Capacitor.isNativePlatform()) return false
+ try {
+ const r = await Plugin.isEnabled()
+ return !!r?.enabled
+ } catch {
+ return false
+ }
+ },
+ async openSettings() {
+ if (!Capacitor.isNativePlatform()) return
+ try {
+ await Plugin.openSettings()
+ } catch {
+ // 무시
+ }
+ },
+}
diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue
index 7fa2399..672532f 100644
--- a/src/views/account/AccountView.vue
+++ b/src/views/account/AccountView.vue
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
+import { cardNotif } from '@/native/cardNotif'
import { Capacitor } from '@capacitor/core'
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
@@ -17,6 +18,16 @@ const error = ref(null)
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
+// 카드 결제 알림 자동인식 권한 (네이티브 전용)
+const notifNative = cardNotif.isNative()
+const notifEnabled = ref(true) // 미허용일 때만 배너 노출
+async function checkNotifPermission() {
+ if (notifNative) notifEnabled.value = await cardNotif.isEnabled()
+}
+function openNotifSettings() {
+ cardNotif.openSettings()
+}
+
async function loadPendingCount() {
try {
pendingCount.value = (await accountApi.pendingCount()).count || 0
@@ -491,6 +502,7 @@ onMounted(async () => {
loadWallets()
loadCategories()
loadPendingCount()
+ checkNotifPermission()
})
@@ -501,6 +513,12 @@ onMounted(async () => {
+
+
+ 💳 카드 결제 알림을 자동으로 가계부에 등록하려면 알림 접근 권한이 필요합니다.
+
+
+
{{ periodLabel }}
@@ -932,6 +950,29 @@ button.primary {
cursor: pointer;
white-space: nowrap;
}
+.notif-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ padding: 0.6rem 0.8rem;
+ margin-bottom: 1rem;
+ border: 1px solid hsla(160, 100%, 37%, 0.4);
+ border-radius: 8px;
+ background: hsla(160, 100%, 37%, 0.08);
+ font-size: 0.83rem;
+}
+.notif-btn {
+ margin-left: auto;
+ padding: 0.35rem 0.8rem;
+ font-size: 0.82rem;
+ border: 1px solid hsla(160, 100%, 37%, 1);
+ border-radius: 4px;
+ background: hsla(160, 100%, 37%, 1);
+ color: #fff;
+ cursor: pointer;
+ white-space: nowrap;
+}
.pending-count {
margin-left: 0.5rem;
font-size: 0.72rem;