AutoPay wg rozwiazania z Gieldy Komunalnej
Deploy Polska Lokalnie / deploy (push) Successful in 11s

- ITN: XML w base64 (pole transactions), bezpieczny parser (bez XXE), podpis
  ze stalej listy pol, zgodnosc ServiceID, kwoty i waluty, idempotencja,
  podpisana odpowiedz XML CONFIRMED/NOTCONFIRMED.
- Start transakcji podpisanym formularzem POST ze strony /platnosc/:orderId
  zamiast linku GET z podpisem w adresie; tytul przelewu w ASCII.
- Losowy OrderID (UUID) zamiast Math.random.
- Adres ITN do wpisania w panelu AutoPay widoczny w panelu admina.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:22:20 +02:00
parent 1c1162c93e
commit 3a555e3b24
14 changed files with 731 additions and 152 deletions
+2 -1
View File
@@ -21,7 +21,7 @@ import { AdminPromotionView } from './admin/promotion';
import { AdminSettingsView } from './admin/settings';
import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal';
import { SiteFooter } from './Footer';
import { ActivationPage, PasswordResetPage, PhoneOtpModal } from './AuthPages';
import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages';
const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href;
const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href;
@@ -3668,6 +3668,7 @@ function App() {
{/* Trasy wymagajace zalogowania */}
<Route element={<ProtectedRoute />}>
<Route path={ROUTES.payment} element={<PaymentRedirectPage />} />
<Route path={ROUTES.guideAdd} element={<GuideAddArticlePage />} />
<Route path={ROUTES.account} element={<AccountDashboardPage savedSearches={savedSearchesState} onOpenSavedSearch={openSavedSearch} />} />
<Route
+71 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ROUTES } from './routes';
import { useAuth } from './auth';
import { apiFetch, useAuth } from './auth';
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.');
@@ -65,6 +65,76 @@ export function ActivationPage() {
);
}
type AutoPayStartForm = {
gatewayUrl: string;
serviceId: string;
orderId: string;
amount: string;
description: string;
customerEmail: string | null;
hash: string;
};
// /platnosc/:orderId - przekierowanie do bramki AutoPay. Transakcje startuje sie formularzem POST,
// wiec podpisane pola pobieramy z API i wysylamy automatycznie. Podpis nie trafia do paska adresu,
// historii przegladarki ani naglowka Referer.
export function PaymentRedirectPage() {
const { orderId } = useParams<{ orderId: string }>();
const [form, setForm] = useState<AutoPayStartForm | null>(null);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const ran = useRef(false);
useEffect(() => {
if (ran.current) {
return;
}
ran.current = true;
apiFetch<AutoPayStartForm>(`/promotion/order/${encodeURIComponent(orderId ?? '')}/autopay-form`)
.then(setForm)
.catch((e) => setError(errMsg(e)));
}, [orderId]);
// Automatyczna wysylka formularza po pobraniu podpisanych pol.
useEffect(() => {
if (form) {
formRef.current?.submit();
}
}, [form]);
if (error) {
return (
<AuthStatusCard>
<div className="auth-status-icon err">!</div>
<h1>Nie udało się rozpocząć płatności</h1>
<p className="auth-status-lead">{error}</p>
<Link className="auth-status-btn" to={ROUTES.accountListings}>Wróć do moich ofert</Link>
</AuthStatusCard>
);
}
return (
<AuthStatusCard>
<h1>Przekierowanie do płatności</h1>
<p className="auth-status-lead">
{form ? 'Za chwilę nastąpi przekierowanie do AutoPay...' : 'Przygotowujemy bezpieczną płatność...'}
</p>
{form && (
<form ref={formRef} method="POST" action={form.gatewayUrl}>
<input type="hidden" name="ServiceID" value={form.serviceId} />
<input type="hidden" name="OrderID" value={form.orderId} />
<input type="hidden" name="Amount" value={form.amount} />
<input type="hidden" name="Description" value={form.description} />
{form.customerEmail && <input type="hidden" name="CustomerEmail" value={form.customerEmail} />}
<input type="hidden" name="Hash" value={form.hash} />
{/* Zapasowy przycisk, gdyby automatyczna wysylka nie zadzialala. */}
<button className="auth-status-btn" type="submit">Kontynuuj do płatności</button>
</form>
)}
</AuthStatusCard>
);
}
// /reset-hasla/:token - ustawienie nowego hasla.
export function PasswordResetPage() {
const { token } = useParams<{ token: string }>();
+1 -1
View File
@@ -217,7 +217,7 @@ export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
<h2>Bramka szybkich płatności - AutoPay</h2>
</div>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny.</p>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn</p>
<div className="mkt-config-grid">
<label>ServiceID
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
+7
View File
@@ -54,6 +54,8 @@ export const ROUTES = {
privacy: '/polityka-prywatnosci',
activation: '/aktywacja/:token',
passwordReset: '/reset-hasla/:token',
// Przekierowanie do bramki AutoPay (podpisany formularz POST) dla zamowienia promowania.
payment: '/platnosc/:orderId',
notifications: '/powiadomienia',
favorites: '/ulubione',
messages: '/wiadomosci',
@@ -123,6 +125,11 @@ export function newConversationPath(listingId: number): string {
return `/wiadomosci/nowa/${listingId}`;
}
// Przekierowanie do bramki platnosci dla zamowienia promowania (adres zwraca tez backend przy checkout).
export function paymentPath(orderId: number): string {
return `/platnosc/${orderId}`;
}
// Dedykowana strona edycji wlasnego ogloszenia.
export function listingEditPath(id: number): string {
return `/edytuj-ogloszenie/${id}`;
+2 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { ROUTES, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath } from '../src/routes';
import { ROUTES, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath, paymentPath } from '../src/routes';
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
@@ -34,6 +34,7 @@ const PROTECTED_ROUTES = [
ROUTES.messages,
conversationPath(ADMIN_CONVERSATION_SLUG),
newConversationPath(1),
paymentPath(1),
ROUTES.add,
ROUTES.admin,
`${ROUTES.admin}/leady`,