Skip to content

Commit 6588da9

Browse files
feat: add manual bank transaction entry page (#1041)
* feat: add manual bank transaction entry page at /sepa/manuell Add a form-based alternative to XML file upload for creating bank transactions. The form generates a CAMT.053 XML and submits it via the existing POST /bankTx endpoint. Admin-only access enforced via useAdminGuard() on frontend and RoleGuard(BANKING_BOT) on API. * refactor: improve XML builder readability and fix escapeXml crash - Fix escapeXml crash on undefined values for optional address fields - Extract escapeXml, buildPartyXml, buildCamt053Xml outside component - Break long inline XML strings into readable multi-line structure - Only emit address XML tags for fields that have values * fix: remove unused iban parameter from buildPartyXml * fix: align address fields with repo pattern and make bank data configurable - Rename buildingNumber→houseNumber, postalCode→zip to match codebase - Use StyledHorizontalStack for Street+Nr and ZIP+City rows - Replace plain Country input with StyledSearchDropdown<Country> - Add autocomplete attributes matching existing screens - Replace hardcoded ACCOUNT_IBAN/OWNER/BANK with form fields - Fix amount formatting to 2 decimal places - Remove unused async from onSubmit * fix: remove type=submit to prevent double form submission * fix: address all review comments - Extract XML builder to src/util/camt053-builder.ts (#6) - Add IBAN validation via Validations.Iban for both IBAN fields (#2) - Escape bookingDate/valueDate/currency in XML output (#3) - Add NaN guard for parseFloat on amount (#5) - Use crypto.randomUUID() for AcctSvcrRef (#8) - Change button label from "Next" to "Upload" (#10) * Fix navigation path for manual entry button * Fix typo in route path from 'manuell' to 'manual' --------- Co-authored-by: David May <85513542+davidleomay@users.noreply.github.com>
1 parent 6cc7f8b commit 6588da9

4 files changed

Lines changed: 478 additions & 0 deletions

File tree

src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const TransactionScreen = lazy(() => import('./screens/transaction.screen'));
4848
const AccountMerge = lazy(() => import('./screens/account-merge.screen'));
4949
const MailLoginScreen = lazy(() => import('./screens/mail-login.screen'));
5050
const SepaScreen = lazy(() => import('./screens/sepa.screen'));
51+
const SepaManualScreen = lazy(() => import('./screens/sepa-manual.screen'));
5152
const StickersScreen = lazy(() => import('./screens/stickers.screen'));
5253
const BlockchainTransactionScreen = lazy(() => import('./screens/blockchain-tx.screen'));
5354
const EditMailScreen = lazy(() => import('./screens/edit-mail.screen'));
@@ -317,6 +318,10 @@ export const Routes = [
317318
path: 'sepa',
318319
element: withSuspense(<SepaScreen />),
319320
},
321+
{
322+
path: 'sepa/manual',
323+
element: withSuspense(<SepaManualScreen />),
324+
},
320325
{
321326
path: 'stickers',
322327
element: withSuspense(<StickersScreen />),

src/screens/sepa-manual.screen.tsx

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { ApiError, Country, useApi, Utils, Validations } from '@dfx.swiss/react';
2+
import {
3+
DfxIcon,
4+
Form,
5+
IconSize,
6+
IconVariant,
7+
StyledButton,
8+
StyledButtonWidth,
9+
StyledDropdown,
10+
StyledHorizontalStack,
11+
StyledInput,
12+
StyledSearchDropdown,
13+
StyledVerticalStack,
14+
} from '@dfx.swiss/react-components';
15+
import { useState } from 'react';
16+
import { useForm } from 'react-hook-form';
17+
import { ErrorHint } from 'src/components/error-hint';
18+
import { useLayoutContext } from 'src/contexts/layout.context';
19+
import { useLayoutOptions } from 'src/hooks/layout-config.hook';
20+
import { buildCamt053Xml } from 'src/util/camt053-builder';
21+
import { useSettingsContext } from '../contexts/settings.context';
22+
import { useAdminGuard } from '../hooks/guard.hook';
23+
24+
enum CreditDebitIndicator {
25+
CRDT = 'CRDT',
26+
DBIT = 'DBIT',
27+
}
28+
29+
interface ManualBankTxForm {
30+
bookingDate: string;
31+
valueDate: string;
32+
amount: string;
33+
currency: string;
34+
direction: CreditDebitIndicator;
35+
accountIban: string;
36+
accountOwner: string;
37+
accountBank: string;
38+
name: string;
39+
street: string;
40+
houseNumber: string;
41+
zip: string;
42+
city: string;
43+
country: Country;
44+
iban: string;
45+
remittanceInfo: string;
46+
}
47+
48+
const CURRENCIES = ['EUR', 'CHF', 'USD'];
49+
50+
export default function SepaManualScreen(): JSX.Element {
51+
const { translate, translateError, allowedCountries } = useSettingsContext();
52+
const { call } = useApi();
53+
const { rootRef } = useLayoutContext();
54+
55+
const [isUploading, setIsUploading] = useState(false);
56+
const [showNotification, setShowNotification] = useState(false);
57+
const [error, setError] = useState<string>();
58+
59+
useAdminGuard();
60+
61+
const {
62+
control,
63+
handleSubmit,
64+
reset,
65+
formState: { isValid, errors },
66+
} = useForm<ManualBankTxForm>({
67+
mode: 'onChange',
68+
defaultValues: {
69+
currency: 'EUR',
70+
direction: CreditDebitIndicator.CRDT,
71+
},
72+
});
73+
74+
function onSubmit(data: ManualBankTxForm) {
75+
const xml = buildCamt053Xml({ ...data, country: data.country?.symbol });
76+
const file = new File([xml], 'manual-bank-tx.xml', { type: 'text/xml' });
77+
78+
const fileData = new FormData();
79+
fileData.append('files', file);
80+
81+
setIsUploading(true);
82+
setError(undefined);
83+
call({
84+
url: 'bankTx',
85+
method: 'POST',
86+
data: fileData,
87+
noJson: true,
88+
})
89+
.then(() => {
90+
setIsUploading(false);
91+
toggleNotification();
92+
reset();
93+
})
94+
.catch((e: ApiError) => {
95+
setIsUploading(false);
96+
setError(e.message);
97+
});
98+
}
99+
100+
const toggleNotification = () => {
101+
setShowNotification(true);
102+
setTimeout(() => setShowNotification(false), 2000);
103+
};
104+
105+
const rules = Utils.createRules({
106+
bookingDate: [Validations.Required],
107+
valueDate: [Validations.Required],
108+
amount: [Validations.Required],
109+
currency: [Validations.Required],
110+
direction: [Validations.Required],
111+
accountIban: [Validations.Required, Validations.Iban(allowedCountries)],
112+
accountOwner: [Validations.Required],
113+
accountBank: [Validations.Required],
114+
name: [Validations.Required],
115+
iban: [Validations.Required, Validations.Iban(allowedCountries)],
116+
remittanceInfo: [Validations.Required],
117+
});
118+
119+
useLayoutOptions({ title: translate('screens/kyc', 'Manual bank transaction') });
120+
121+
return (
122+
<Form control={control} rules={rules} errors={errors} onSubmit={handleSubmit(onSubmit)} translate={translateError}>
123+
<StyledVerticalStack gap={6} full center>
124+
<StyledVerticalStack gap={2} full>
125+
<p className="flex flex-row justify-between w-full text-dfxGray-700 text-xs font-semibold uppercase text-start px-3">
126+
<span>{translate('screens/kyc', 'Transaction details')}</span>
127+
<span
128+
className={`flex flex-row gap-1 items-center text-dfxRed-100 font-normal transition-opacity duration-200 ${
129+
showNotification ? 'opacity-100' : 'opacity-0'
130+
}`}
131+
>
132+
<DfxIcon icon={IconVariant.CHECK} size={IconSize.SM} />
133+
{translate('screens/kyc', 'Uploaded')}
134+
</span>
135+
</p>
136+
137+
<StyledInput name="bookingDate" type="date" label={translate('screens/kyc', 'Booking date')} full smallLabel />
138+
<StyledInput name="valueDate" type="date" label={translate('screens/kyc', 'Value date')} full smallLabel />
139+
<StyledInput name="amount" type="number" label={translate('screens/kyc', 'Amount')} placeholder="0.00" full smallLabel />
140+
<StyledDropdown
141+
name="currency"
142+
label={translate('screens/kyc', 'Currency')}
143+
items={CURRENCIES}
144+
labelFunc={(item) => item}
145+
full
146+
smallLabel
147+
/>
148+
<StyledDropdown
149+
name="direction"
150+
label={translate('screens/kyc', 'Direction')}
151+
items={Object.values(CreditDebitIndicator)}
152+
labelFunc={(item) => (item === CreditDebitIndicator.CRDT ? 'Credit (incoming)' : 'Debit (outgoing)')}
153+
full
154+
smallLabel
155+
/>
156+
</StyledVerticalStack>
157+
158+
<StyledVerticalStack gap={2} full>
159+
<p className="w-full text-dfxGray-700 text-xs font-semibold uppercase text-start px-3">
160+
{translate('screens/kyc', 'Account')}
161+
</p>
162+
163+
<StyledInput name="accountOwner" label={translate('screens/kyc', 'Account owner')} placeholder="DFX AG" full smallLabel />
164+
<StyledInput name="accountIban" label={translate('screens/kyc', 'Account IBAN')} placeholder="CH78 8080 8002 6086 1409 2" full smallLabel />
165+
<StyledInput name="accountBank" label={translate('screens/kyc', 'Bank name')} placeholder="Raiffeisenbank" full smallLabel />
166+
</StyledVerticalStack>
167+
168+
<StyledVerticalStack gap={2} full>
169+
<p className="w-full text-dfxGray-700 text-xs font-semibold uppercase text-start px-3">
170+
{translate('screens/kyc', 'Counterparty')}
171+
</p>
172+
173+
<StyledInput name="name" autocomplete="name" label={translate('screens/kyc', 'Name')} placeholder={translate('screens/kyc', 'John Doe')} full smallLabel />
174+
<StyledHorizontalStack gap={2}>
175+
<StyledInput
176+
name="street"
177+
autocomplete="street"
178+
label={translate('screens/kyc', 'Street')}
179+
placeholder={translate('screens/kyc', 'Street')}
180+
full
181+
smallLabel
182+
/>
183+
<StyledInput
184+
name="houseNumber"
185+
autocomplete="house-number"
186+
label={translate('screens/kyc', 'House nr.')}
187+
placeholder="xx"
188+
small
189+
smallLabel
190+
/>
191+
</StyledHorizontalStack>
192+
<StyledHorizontalStack gap={2}>
193+
<StyledInput
194+
name="zip"
195+
autocomplete="zip"
196+
label={translate('screens/kyc', 'ZIP code')}
197+
placeholder="12345"
198+
small
199+
smallLabel
200+
/>
201+
<StyledInput
202+
name="city"
203+
autocomplete="city"
204+
label={translate('screens/kyc', 'City')}
205+
placeholder={translate('screens/kyc', 'City')}
206+
full
207+
smallLabel
208+
/>
209+
</StyledHorizontalStack>
210+
<StyledSearchDropdown<Country>
211+
rootRef={rootRef}
212+
name="country"
213+
autocomplete="country"
214+
label={translate('screens/kyc', 'Country')}
215+
placeholder={translate('general/actions', 'Select') + '...'}
216+
items={allowedCountries}
217+
labelFunc={(item) => item.name}
218+
filterFunc={(i, s) => !s || [i.name, i.symbol].some((w) => w.toLowerCase().includes(s.toLowerCase()))}
219+
matchFunc={(i, s) => i.name.toLowerCase() === s?.toLowerCase()}
220+
full
221+
smallLabel
222+
/>
223+
<StyledInput name="iban" label={translate('screens/kyc', 'IBAN')} placeholder="DE89 3704 0044 0532 0130 00" full smallLabel />
224+
</StyledVerticalStack>
225+
226+
<StyledVerticalStack gap={2} full>
227+
<p className="w-full text-dfxGray-700 text-xs font-semibold uppercase text-start px-3">
228+
{translate('screens/kyc', 'Payment details')}
229+
</p>
230+
231+
<StyledInput
232+
name="remittanceInfo"
233+
label={translate('screens/kyc', 'Remittance info')}
234+
placeholder="XXXX-XXXX-XXXX"
235+
full
236+
smallLabel
237+
/>
238+
</StyledVerticalStack>
239+
240+
{error && (
241+
<div>
242+
<ErrorHint message={error} />
243+
</div>
244+
)}
245+
246+
<StyledButton
247+
label={translate('general/actions', 'Upload')}
248+
onClick={handleSubmit(onSubmit)}
249+
width={StyledButtonWidth.FULL}
250+
disabled={!isValid}
251+
isLoading={isUploading}
252+
/>
253+
</StyledVerticalStack>
254+
</Form>
255+
);
256+
}

src/screens/sepa.screen.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import {
55
IconSize,
66
IconVariant,
77
StyledButton,
8+
StyledButtonColor,
89
StyledButtonWidth,
910
StyledFileUpload,
1011
StyledVerticalStack,
1112
} from '@dfx.swiss/react-components';
1213
import { useState } from 'react';
1314
import { useForm } from 'react-hook-form';
15+
import { useNavigate } from 'react-router-dom';
1416
import { ErrorHint } from 'src/components/error-hint';
1517
import { useLayoutOptions } from 'src/hooks/layout-config.hook';
1618
import { useSettingsContext } from '../contexts/settings.context';
@@ -23,6 +25,7 @@ interface FormDataFile {
2325
export default function SepaScreen(): JSX.Element {
2426
const { translate, translateError } = useSettingsContext();
2527
const { call } = useApi();
28+
const navigate = useNavigate();
2629

2730
const [isUploading, setIsUploading] = useState(false);
2831
const [showNotification, setShowNotification] = useState(false);
@@ -114,6 +117,13 @@ export default function SepaScreen(): JSX.Element {
114117
disabled={!isValid}
115118
isLoading={isUploading}
116119
/>
120+
121+
<StyledButton
122+
label={translate('screens/kyc', 'Manual entry')}
123+
onClick={() => navigate('/sepa/manual')}
124+
width={StyledButtonWidth.FULL}
125+
color={StyledButtonColor.STURDY_WHITE}
126+
/>
117127
</StyledVerticalStack>
118128
</Form>
119129
);

0 commit comments

Comments
 (0)