diff --git a/app/Http/Controllers/SepaController.php b/app/Http/Controllers/SepaController.php index fb222d95..bd1f7216 100644 --- a/app/Http/Controllers/SepaController.php +++ b/app/Http/Controllers/SepaController.php @@ -264,6 +264,33 @@ public function paymentRequests(Request $request, Store $store): JsonResponse } } + /** + * Public NOP diagnostics ("Kde je moja platba") for one payment request. + * Only NOP-shaped references (QR- + 32 hex) can be looked up - anything + * else answers invalid_id here without a round trip to BTCPay. + */ + public function nopHistory(Store $store, string $reference): JsonResponse + { + if (preg_match('/^QR-[0-9a-fA-F]{32}$/', $reference) !== 1) { + return response()->json(['data' => [ + 'reference' => $reference, + 'status' => 'invalid_id', + 'environment' => 'PROD', + 'message' => null, + ]]); + } + + $userApiKey = $this->ownerApiKey($store); + + try { + $result = $this->sepaService->nopHistory($store->btcpay_store_id, $reference, $userApiKey); + + return response()->json(['data' => $result]); + } catch (BtcPayException $e) { + return $this->handleBtcPayError($e); + } + } + public function confirmPaymentRequest(Store $store, string $reference): JsonResponse { $userApiKey = $this->ownerApiKey($store); diff --git a/app/Services/BtcPay/SepaService.php b/app/Services/BtcPay/SepaService.php index 073c2ddd..a00d557e 100644 --- a/app/Services/BtcPay/SepaService.php +++ b/app/Services/BtcPay/SepaService.php @@ -129,6 +129,18 @@ public function listPaymentRequests(string $storeId, ?string $state = null, ?str }); } + /** + * "Where is my payment": public NOP diagnostics timeline of a QR- + * payment request (plugin >= 0.8.0). Read-only; status found | + * not_found | invalid_id | unavailable. + */ + public function nopHistory(string $storeId, string $reference, ?string $userApiKey = null): array + { + return $this->client->withUserKey($userApiKey, function () use ($storeId, $reference) { + return $this->client->get($this->base($storeId).'/payment-requests/'.rawurlencode($reference).'/nop-history'); + }); + } + /** * @return array { outcome } */ diff --git a/docs/user/en/sepa-instant-qr.md b/docs/user/en/sepa-instant-qr.md index d20221aa..d8e12022 100644 --- a/docs/user/en/sepa-instant-qr.md +++ b/docs/user/en/sepa-instant-qr.md @@ -15,6 +15,18 @@ meta_description: Accept euro bank transfers via a SEPA Instant QR code, alongsi 2. Enter your bank details (the account that should receive the euro transfers) and enable the method. 3. Optionally set up e-mail confirmation of incoming payments so the store can reconcile them. +## Where is my payment + +Every awaiting or needs-review payment whose reference starts with `QR-` has a **Where is my payment** button. It asks the public diagnostics service of the Slovak Financial Administration's instant payment notifier (NOP) what it knows about that reference and shows the timeline: transaction id created, bank notification stored, matched to the cash register, published, received. + +What to expect: + +- **NOP knows this id** appears for stores that confirm through NOP with an eKasa certificate, and for payments a notification-enabled bank account (Tatra banka, SLSP) reported. +- **NOP has not seen this id** is the normal answer for stores that confirm manually, through Fio or through e-mail: their references are generated locally, so NOP has nothing to show even when the money has already arrived (and even when Fio or e-mail confirmation has already settled the invoice). It does not mean the customer has not paid - check your bank account. +- The timeline never says which account was credited. Always check the transfer in your banking app before marking a payment as paid - satflux does not confirm anything from this screen. + +The same data is on [kdejemojaplatba.sk](https://www.kdejemojaplatba.sk/), an independent viewer of the same service. + ## Notes - Available to all accounts, including guests. diff --git a/docs/user/sk/sepa-instant-qr.md b/docs/user/sk/sepa-instant-qr.md index 82ea50a4..7e8b655d 100644 --- a/docs/user/sk/sepa-instant-qr.md +++ b/docs/user/sk/sepa-instant-qr.md @@ -15,6 +15,18 @@ meta_description: Prijímajte eurové bankové prevody cez SEPA Instant QR kód, 2. Zadajte bankové údaje (účet, ktorý má prijímať eurové prevody) a zapnite metódu. 3. Voliteľne nastavte e-mailové potvrdzovanie prichádzajúcich platieb, aby ich obchod vedel spárovať. +## Kde je moja platba + +Každá čakajúca platba alebo platba na kontrolu, ktorej referencia začína na `QR-`, má tlačidlo **Kde je moja platba**. Opýta sa verejnej diagnostiky Notifikátora okamžitých platieb (NOP) Finančnej správy SR, čo o tejto referencii vie, a ukáže časovú os: vznik ID transakcie, uloženie oznámenia banky, spárovanie s pokladnicou, sprístupnenie, prijatie. + +Čo očakávať: + +- **NOP toto ID pozná** sa zobrazí pri obchodoch, ktoré potvrdzujú cez NOP s eKasa certifikátom, a pri platbách, ktoré nahlásil notifikačný bankový účet (Tatra banka, SLSP). +- **NOP toto ID nevidel** je bežná odpoveď pri obchodoch s manuálnym, Fio alebo e-mailovým potvrdzovaním: ich referencie sa generujú lokálne, takže NOP nemá čo ukázať, ani keď peniaze už prišli (a ani keď Fio alebo e-mailové potvrdenie faktúru už uzavrelo). Neznamená to, že zákazník nezaplatil - skontrolujte bankový účet. +- Časová os nikdy nehovorí, na ktorý účet peniaze prišli. Pred označením platby ako zaplatenej ju vždy skontrolujte v bankovej aplikácii - satflux z tejto obrazovky nič nepotvrdzuje. + +Rovnaké údaje ukazuje aj [kdejemojaplatba.sk](https://www.kdejemojaplatba.sk/), nezávislý prehliadač tej istej služby. + ## Poznámky - Dostupné pre všetky účty vrátane hostí. diff --git a/resources/js/__tests__/sepaPage.test.ts b/resources/js/__tests__/sepaPage.test.ts index 03bbc8c5..9e29b957 100644 --- a/resources/js/__tests__/sepaPage.test.ts +++ b/resources/js/__tests__/sepaPage.test.ts @@ -48,9 +48,15 @@ const baseSettings = { }; let settings = { ...baseSettings }; +let nopHistoryStatus: "found" | "not_found" | "invalid_id" | "unavailable" = "found"; -function primeApi({ available = true, overrides = {} as Record } = {}) { +function primeApi({ + available = true, + overrides = {} as Record, + nopStatus = "found" as typeof nopHistoryStatus, +} = {}) { settings = { ...baseSettings, ...overrides }; + nopHistoryStatus = nopStatus; apiMock.get.mockImplementation((url: string) => { if (url.includes("/sepa/status")) { return Promise.resolve({ data: { data: { available } } }); @@ -58,6 +64,26 @@ function primeApi({ available = true, overrides = {} as Record if (url.includes("/sepa/settings")) { return Promise.resolve({ data: { data: settings } }); } + if (url.includes("/nop-history")) { + return Promise.resolve({ + data: { + data: { + reference: "QR-ab29e346f1d841c8a95a63d857490818", + status: nopHistoryStatus, + environment: "PROD", + message: nopHistoryStatus === "unavailable" ? "NOP rate limit reached" : null, + createdAt: nopHistoryStatus === "found" ? "2026-09-16T08:00:00+00:00" : null, + indexedAt: nopHistoryStatus === "found" ? "2026-09-16T08:01:10+00:00" : null, + matchedAt: null, + publishedAt: null, + receivedAt: null, + organizationName: nopHistoryStatus === "found" ? "Kaviaren s.r.o." : null, + amount: nopHistoryStatus === "found" ? 12.5 : null, + currency: nopHistoryStatus === "found" ? "EUR" : null, + }, + }, + }); + } if (url.includes("/sepa/payment-requests")) { return Promise.resolve({ data: { @@ -142,6 +168,39 @@ describe("Sepa store page", () => { expect(wrapper.text()).toContain("sepa.mark_paid"); }); + it("opens the public NOP timeline for a QR- request", async () => { + primeApi(); + const wrapper = await mountPage(); + expect(wrapper.find('[data-testid="sepa-nop-history"]').exists()).toBe(false); + + const button = wrapper.findAll("button").find((b) => b.text() === "sepa.nop_history_button"); + expect(button).toBeDefined(); + await button!.trigger("click"); + await flushPromises(); + + const modal = wrapper.find('[data-testid="sepa-nop-history"]'); + expect(modal.exists()).toBe(true); + expect(modal.text()).toContain("sepa.nop_history_found"); + expect(modal.text()).toContain("sepa.nop_history_step_indexed"); + expect(modal.text()).toContain("sepa.nop_history_disclaimer"); + expect(apiMock.get).toHaveBeenCalledWith( + "/stores/store-1/sepa/payment-requests/QR-ab29e346f1d841c8a95a63d857490818/nop-history", + ); + }); + + it("explains an unknown id instead of showing a timeline", async () => { + primeApi({ nopStatus: "not_found" }); + const wrapper = await mountPage(); + + const button = wrapper.findAll("button").find((b) => b.text() === "sepa.nop_history_button"); + await button!.trigger("click"); + await flushPromises(); + + const modal = wrapper.find('[data-testid="sepa-nop-history"]'); + expect(modal.text()).toContain("sepa.nop_history_not_found"); + expect(modal.text()).not.toContain("sepa.nop_history_step_created"); + }); + it("shows the plugin-unavailable notice when the probe fails", async () => { primeApi({ available: false }); const wrapper = await mountPage(); diff --git a/resources/js/locales/cs.json b/resources/js/locales/cs.json index 35c0a1bf..72cb25ba 100644 --- a/resources/js/locales/cs.json +++ b/resources/js/locales/cs.json @@ -4536,6 +4536,25 @@ "column_created": "Vytvořeno", "mark_paid": "Označit jako zaplacené", "payment_confirmed": "Platba označena jako zaplacená.", - "payment_confirm_failed": "Platbu se nepodařilo potvrdit" + "payment_confirm_failed": "Platbu se nepodařilo potvrdit", + "nop_history_button": "Kde je moje platba", + "nop_history_title": "Kde je moje platba", + "nop_history_close": "Zavřít", + "nop_history_loading": "Dotazujeme se NOP...", + "nop_history_failed": "Stav z NOP se nepodařilo načíst", + "nop_history_intro": "Veřejná diagnostika NOP slovenské Finanční správy (prostředí {environment}). Pouze pro čtení - nic se zde nepotvrzuje.", + "nop_history_found": "NOP toto ID transakce zná.", + "nop_history_amount": "Banka nahlásila {amount} {currency}.", + "nop_history_org": "Pokladna: {name}", + "nop_history_not_found": "NOP toto ID neviděl. To je běžné u obchodů bez NOP backendu: reference se generuje lokálně, takže NOP nemá co ukázat, ani když peníze už dorazily. Neznamená to, že zákazník nezaplatil - převod ověřte v bankovní aplikaci.", + "nop_history_invalid": "Tato reference není ID transakce NOP.", + "nop_history_unavailable": "NOP se teď nedá dotázat: {message}", + "nop_history_disclaimer": "Časová osa ukazuje cestu platby přes NOP, ne na který účet přišla. Před označením jako zaplacené převod zkontrolujte v bankovní aplikaci.", + "nop_history_step_created": "Vznik ID transakce", + "nop_history_step_indexed": "Oznámení banky uloženo", + "nop_history_step_matched": "Spárováno s pokladnou", + "nop_history_step_published": "Zpřístupněno pokladně", + "nop_history_step_received": "Přijato pokladnou", + "nop_history_pending_step": "zatím ne" } } diff --git a/resources/js/locales/de.json b/resources/js/locales/de.json index 023d1e1e..ddbf0977 100644 --- a/resources/js/locales/de.json +++ b/resources/js/locales/de.json @@ -4536,6 +4536,25 @@ "column_created": "Erstellt", "mark_paid": "Als bezahlt markieren", "payment_confirmed": "Zahlung als bezahlt markiert.", - "payment_confirm_failed": "Zahlung konnte nicht bestätigt werden" + "payment_confirm_failed": "Zahlung konnte nicht bestätigt werden", + "nop_history_button": "Wo ist meine Zahlung", + "nop_history_title": "Wo ist meine Zahlung", + "nop_history_close": "Schließen", + "nop_history_loading": "NOP wird abgefragt...", + "nop_history_failed": "Der NOP-Status konnte nicht geladen werden", + "nop_history_intro": "Öffentliche NOP-Diagnose der slowakischen Finanzverwaltung (Umgebung {environment}). Nur lesend - hier wird nichts bestätigt.", + "nop_history_found": "NOP kennt diese Transaktions-ID.", + "nop_history_amount": "Eine Bank hat {amount} {currency} gemeldet.", + "nop_history_org": "Kasse: {name}", + "nop_history_not_found": "NOP hat diese ID nicht gesehen. Das ist normal für Shops ohne NOP-Backend: die Referenz wird lokal erzeugt, daher zeigt NOP nichts, auch wenn das Geld bereits eingegangen ist. Es bedeutet nicht, dass der Kunde nicht bezahlt hat - prüfen Sie die Überweisung in Ihrer Banking-App.", + "nop_history_invalid": "Diese Referenz ist keine NOP-Transaktions-ID.", + "nop_history_unavailable": "NOP kann gerade nicht abgefragt werden: {message}", + "nop_history_disclaimer": "Die Zeitleiste zeigt den Weg der Zahlung durch NOP, nicht welches Konto gutgeschrieben wurde. Prüfen Sie die Überweisung in Ihrer Banking-App, bevor Sie sie als bezahlt markieren.", + "nop_history_step_created": "Transaktions-ID erstellt", + "nop_history_step_indexed": "Bankbenachrichtigung gespeichert", + "nop_history_step_matched": "Der Kasse zugeordnet", + "nop_history_step_published": "Für die Kasse bereitgestellt", + "nop_history_step_received": "Von der Kasse empfangen", + "nop_history_pending_step": "noch nicht" } } diff --git a/resources/js/locales/en.json b/resources/js/locales/en.json index d9bc7c02..81463d48 100644 --- a/resources/js/locales/en.json +++ b/resources/js/locales/en.json @@ -4536,6 +4536,25 @@ "column_created": "Created", "mark_paid": "Mark as paid", "payment_confirmed": "Payment marked as paid.", - "payment_confirm_failed": "Failed to confirm the payment" + "payment_confirm_failed": "Failed to confirm the payment", + "nop_history_button": "Where is my payment", + "nop_history_title": "Where is my payment", + "nop_history_close": "Close", + "nop_history_loading": "Asking NOP...", + "nop_history_failed": "Could not load the NOP status", + "nop_history_intro": "Public NOP diagnostics of the Slovak Financial Administration ({environment} environment). Read-only - nothing is confirmed here.", + "nop_history_found": "NOP knows this transaction id.", + "nop_history_amount": "A bank reported {amount} {currency}.", + "nop_history_org": "Cash register: {name}", + "nop_history_not_found": "NOP has not seen this id. That is expected for stores without a NOP backend: the reference is generated locally, so NOP has nothing to show even when the money has already arrived. It does not mean the customer has not paid - check the transfer in your banking app.", + "nop_history_invalid": "This reference is not a NOP transaction id.", + "nop_history_unavailable": "NOP could not be asked right now: {message}", + "nop_history_disclaimer": "The timeline shows the payment's journey through NOP, not which account was credited. Check the transfer in your banking app before marking it as paid.", + "nop_history_step_created": "Transaction id created", + "nop_history_step_indexed": "Bank notification stored", + "nop_history_step_matched": "Matched to the cash register", + "nop_history_step_published": "Published to the cash register", + "nop_history_step_received": "Received by the cash register", + "nop_history_pending_step": "not yet" } } diff --git a/resources/js/locales/es.json b/resources/js/locales/es.json index 858a5962..5fe95c44 100644 --- a/resources/js/locales/es.json +++ b/resources/js/locales/es.json @@ -4536,6 +4536,25 @@ "column_created": "Creado", "mark_paid": "Marcar como pagado", "payment_confirmed": "Pago marcado como pagado.", - "payment_confirm_failed": "No se pudo confirmar el pago" + "payment_confirm_failed": "No se pudo confirmar el pago", + "nop_history_button": "Dónde está mi pago", + "nop_history_title": "Dónde está mi pago", + "nop_history_close": "Cerrar", + "nop_history_loading": "Consultando NOP...", + "nop_history_failed": "No se pudo cargar el estado de NOP", + "nop_history_intro": "Diagnóstico público de NOP de la Administración Financiera eslovaca (entorno {environment}). Solo lectura - aquí no se confirma nada.", + "nop_history_found": "NOP conoce este ID de transacción.", + "nop_history_amount": "Un banco informó {amount} {currency}.", + "nop_history_org": "Caja registradora: {name}", + "nop_history_not_found": "NOP no ha visto este ID. Es normal en tiendas sin backend NOP: la referencia se genera localmente, así que NOP no muestra nada aunque el dinero ya haya llegado. No significa que el cliente no haya pagado - verifica la transferencia en tu app bancaria.", + "nop_history_invalid": "Esta referencia no es un ID de transacción de NOP.", + "nop_history_unavailable": "No se pudo consultar NOP ahora mismo: {message}", + "nop_history_disclaimer": "La línea de tiempo muestra el recorrido del pago por NOP, no a qué cuenta se abonó. Comprueba la transferencia en tu app bancaria antes de marcarla como pagada.", + "nop_history_step_created": "ID de transacción creado", + "nop_history_step_indexed": "Notificación bancaria guardada", + "nop_history_step_matched": "Emparejado con la caja", + "nop_history_step_published": "Publicado para la caja", + "nop_history_step_received": "Recibido por la caja", + "nop_history_pending_step": "todavía no" } } diff --git a/resources/js/locales/sk.json b/resources/js/locales/sk.json index 02573dff..7c0014e5 100644 --- a/resources/js/locales/sk.json +++ b/resources/js/locales/sk.json @@ -4540,6 +4540,25 @@ "column_created": "Vytvorené", "mark_paid": "Označiť ako zaplatené", "payment_confirmed": "Platba označená ako zaplatená.", - "payment_confirm_failed": "Platbu sa nepodarilo potvrdiť" + "payment_confirm_failed": "Platbu sa nepodarilo potvrdiť", + "nop_history_button": "Kde je moja platba", + "nop_history_title": "Kde je moja platba", + "nop_history_close": "Zavrieť", + "nop_history_loading": "Pýtame sa NOP...", + "nop_history_failed": "Stav z NOP sa nepodarilo načítať", + "nop_history_intro": "Verejná diagnostika NOP Finančnej správy SR (prostredie {environment}). Len na čítanie - nič sa tu nepotvrdzuje.", + "nop_history_found": "NOP toto ID transakcie pozná.", + "nop_history_amount": "Banka nahlásila {amount} {currency}.", + "nop_history_org": "Pokladnica: {name}", + "nop_history_not_found": "NOP toto ID nevidel. To je bežné pri obchodoch bez NOP backendu: referencia sa generuje lokálne, takže NOP nemá čo ukázať, ani keď peniaze už prišli. Neznamená to, že zákazník nezaplatil - prevod overte v bankovej aplikácii.", + "nop_history_invalid": "Táto referencia nie je ID transakcie NOP.", + "nop_history_unavailable": "NOP sa teraz nedá opýtať: {message}", + "nop_history_disclaimer": "Časová os ukazuje cestu platby cez NOP, nie na ktorý účet prišla. Pred označením ako zaplatené prevod skontrolujte v bankovej aplikácii.", + "nop_history_step_created": "Vznik ID transakcie", + "nop_history_step_indexed": "Oznámenie banky uložené", + "nop_history_step_matched": "Spárované s pokladnicou", + "nop_history_step_published": "Sprístupnené pokladnici", + "nop_history_step_received": "Prijaté pokladnicou", + "nop_history_pending_step": "zatiaľ nie" } } diff --git a/resources/js/pages/stores/Sepa.vue b/resources/js/pages/stores/Sepa.vue index 4dbdfa88..9c1c4fcf 100644 --- a/resources/js/pages/stores/Sepa.vue +++ b/resources/js/pages/stores/Sepa.vue @@ -454,7 +454,16 @@ {{ row.reference }} {{ formatAmount(row.amountDue) }} {{ row.currency }} {{ row.reviewReason }} - + + + + +

{{ t("sepa.nop_history_loading") }}

+

{{ nopHistory.error }}

+ + + @@ -555,6 +635,23 @@ interface SepaPaymentRequest { reviewReason: string | null; } +interface SepaNopHistory { + reference: string; + status: "found" | "not_found" | "invalid_id" | "unavailable"; + environment: string; + message: string | null; + transactionId?: string | null; + createdAt?: string | null; + indexedAt?: string | null; + matchedAt?: string | null; + publishedAt?: string | null; + receivedAt?: string | null; + organizationName?: string | null; + nopStatus?: string | null; + amount?: number | null; + currency?: string | null; +} + const { t, locale } = useI18n(); const flashStore = useFlashStore(); const appsStore = useAppsStore(); @@ -572,6 +669,13 @@ const confirming = ref(null); const requests = ref([]); const bmail = ref<{ enabled: boolean; address: string | null }>({ enabled: false, address: null }); const bmailCopied = ref(false); +const nopHistory = reactive<{ + open: boolean; + loading: boolean; + reference: string; + data: SepaNopHistory | null; + error: string | null; +}>({ open: false, loading: false, reference: "", data: null, error: null }); const fieldErrors = reactive>({}); const form = reactive({ @@ -615,6 +719,17 @@ function backendLabel(backend: string): string { } const pendingRequests = computed(() => requests.value.filter((r) => r.state === "PENDING")); +const nopHistorySteps = computed(() => { + const d = nopHistory.data; + if (!d) return []; + return [ + { key: "created", label: "sepa.nop_history_step_created", at: d.createdAt ?? null }, + { key: "indexed", label: "sepa.nop_history_step_indexed", at: d.indexedAt ?? null }, + { key: "matched", label: "sepa.nop_history_step_matched", at: d.matchedAt ?? null }, + { key: "published", label: "sepa.nop_history_step_published", at: d.publishedAt ?? null }, + { key: "received", label: "sepa.nop_history_step_received", at: d.receivedAt ?? null }, + ]; +}); const reviewRequests = computed(() => requests.value.filter((r) => r.state === "MANUAL_REVIEW")); function applySettings(data: SepaSettings) { @@ -820,6 +935,28 @@ async function confirmRequest(reference: string) { } } +async function openNopHistory(reference: string) { + nopHistory.open = true; + nopHistory.loading = true; + nopHistory.reference = reference; + nopHistory.data = null; + nopHistory.error = null; + try { + const res = await api.get( + `/stores/${storeId.value}/sepa/payment-requests/${encodeURIComponent(reference)}/nop-history`, + ); + nopHistory.data = res.data?.data ?? res.data; + } catch (err: unknown) { + nopHistory.error = getApiErrorMessage(err, t("sepa.nop_history_failed")); + } finally { + nopHistory.loading = false; + } +} + +function closeNopHistory() { + nopHistory.open = false; +} + async function copyBmailAddress() { if (!bmail.value.address) return; try { diff --git a/routes/api.php b/routes/api.php index 176817d8..ef6474a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -927,6 +927,9 @@ Route::post('/test', [SepaController::class, 'testBackend']) ->middleware('throttle:10,1'); Route::get('/payment-requests', [SepaController::class, 'paymentRequests']); + Route::get('/payment-requests/{reference}/nop-history', [SepaController::class, 'nopHistory']) + ->where('reference', '[a-zA-Z0-9_-]+') + ->middleware('throttle:30,1'); Route::post('/payment-requests/{reference}/confirm', [SepaController::class, 'confirmPaymentRequest']) ->where('reference', '[a-zA-Z0-9_-]+') ->middleware(AuditLog::class.':sepa.payment_confirmed'); diff --git a/tests/Feature/SepaTest.php b/tests/Feature/SepaTest.php index 9200825f..c7f0fbc5 100644 --- a/tests/Feature/SepaTest.php +++ b/tests/Feature/SepaTest.php @@ -214,6 +214,53 @@ public function test_payment_requests_pass_state_filter(): void Http::assertSent(fn (Request $request) => str_contains((string) $request->url(), 'state=pending')); } + public function test_nop_history_proxies_the_public_nop_timeline(): void + { + $reference = 'QR-ab29e346f1d841c8a95a63d857490818'; + Http::fake([ + $this->pluginBase().'/payment-requests/'.$reference.'/nop-history' => Http::response([ + 'reference' => $reference, + 'status' => 'found', + 'environment' => 'PROD', + 'message' => null, + 'createdAt' => '2026-09-16T08:00:00+00:00', + 'indexedAt' => '2026-09-16T08:01:10+00:00', + 'matchedAt' => null, + 'organizationName' => 'Kaviaren s.r.o.', + 'amount' => 12.5, + 'currency' => 'EUR', + ]), + ]); + + $response = $this->getJson("/api/stores/{$this->store->id}/sepa/payment-requests/{$reference}/nop-history"); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'found'); + $response->assertJsonPath('data.amount', 12.5); + $response->assertJsonPath('data.organizationName', 'Kaviaren s.r.o.'); + } + + public function test_nop_history_rejects_non_nop_references_without_calling_btcpay(): void + { + Http::fake(); + + $response = $this->getJson("/api/stores/{$this->store->id}/sepa/payment-requests/1234567890/nop-history"); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'invalid_id'); + Http::assertNothingSent(); + } + + public function test_nop_history_is_owner_scoped(): void + { + Http::fake(); + $otherStore = Store::factory()->create(); + + $this->getJson("/api/stores/{$otherStore->id}/sepa/payment-requests/QR-ab29e346f1d841c8a95a63d857490818/nop-history") + ->assertForbidden(); + Http::assertNothingSent(); + } + public function test_confirm_payment_request_proxies(): void { $reference = 'QR-ab29e346f1d841c8a95a63d857490818';