From d05611c27a2fc6a83be41d19d8a8737c27a32198 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 25 Sep 2026 12:18:34 +0300 Subject: [PATCH] Read EN 18184 codes and build them from a profile in the settings The wallet now reads the point-of-interaction format of EN 18184:2025 (EPC024-22) beside EPC069-12. A scanned or pasted https address goes to the EN 18184 decoder. A code with all data in clear gets the usual review, plus the transfer the payee asks for, trade name, reference party, merchant category, payment context, and the framework, provider and issuer the address names. Token and proxy codes are refused, since only the payee's provider can resolve them, as are other currencies than euro and web addresses without the EN 18184 structure. Nothing is opened or fetched. Building EN 18184 codes needs the framework domain, provider ID and issuer ID a framework issues, so Settings gains a form for them, checked by the encoder. With a profile set, the request screen offers EPC QR or EN 18184 and, for EN 18184, an instant or standard transfer. The code uses the all-data-in-clear profile in the person-to-person context, requires an amount and carries no BIC. Kept requests of either format go into the history, and an EN 18184 request is shared as its own URL. Results carry their format as a PaymentCode union, so the review, the history, the share message and the symbol size cap follow it. All seven languages are extended. The codec now refuses a host whose last label reads as a number, such as "shop.1" or "123". The URL parser threw on some of them and turned others into an IPv4 address, so typing a domain like "shop.1und1.de" could crash the settings form and "123" would have built a code for 0.0.0.123. It also refuses punycode labels, which Node's URL and the parser Expo installs read differently. Closes #27 --- apps/wallet/App.tsx | 31 ++- apps/wallet/README.md | 71 +++++- apps/wallet/src/epc/link.ts | 21 +- apps/wallet/src/epc/payto.ts | 19 +- apps/wallet/src/epc/poi.ts | 269 ++++++++++++++++++++++ apps/wallet/src/epc/request.ts | 170 +++++++++++++- apps/wallet/src/epc/scan.ts | 32 ++- apps/wallet/src/i18n/de.ts | 82 ++++++- apps/wallet/src/i18n/dictionary.ts | 87 ++++++- apps/wallet/src/i18n/en.ts | 81 ++++++- apps/wallet/src/i18n/es.ts | 81 ++++++- apps/wallet/src/i18n/fr.ts | 82 ++++++- apps/wallet/src/i18n/index.ts | 6 +- apps/wallet/src/i18n/it.ts | 81 ++++++- apps/wallet/src/i18n/nl.ts | 81 ++++++- apps/wallet/src/i18n/pl.ts | 81 ++++++- apps/wallet/src/qr/symbol.ts | 23 +- apps/wallet/src/settings/history.ts | 21 +- apps/wallet/src/settings/preferences.ts | 30 ++- apps/wallet/src/ui/ComposedRequest.tsx | 62 ++--- apps/wallet/src/ui/HistoryScreen.tsx | 4 +- apps/wallet/src/ui/PoiScreen.tsx | 151 ++++++++++++ apps/wallet/src/ui/RequestScreen.tsx | 85 ++++++- apps/wallet/src/ui/ScanScreen.tsx | 28 ++- apps/wallet/src/ui/SettingsScreen.tsx | 12 + apps/wallet/test/i18n.test.ts | 8 +- apps/wallet/test/link.test.ts | 19 +- apps/wallet/test/payto.test.ts | 20 +- apps/wallet/test/poi.test.ts | 291 ++++++++++++++++++++++++ apps/wallet/test/request.test.ts | 16 +- apps/wallet/test/scan.test.ts | 27 ++- apps/wallet/test/symbol.test.ts | 4 +- packages/qr/src/epc024/index.ts | 39 +++- packages/qr/test/epc024.test.ts | 48 ++++ 34 files changed, 1996 insertions(+), 167 deletions(-) create mode 100644 apps/wallet/src/epc/poi.ts create mode 100644 apps/wallet/src/ui/PoiScreen.tsx create mode 100644 apps/wallet/test/poi.test.ts diff --git a/apps/wallet/App.tsx b/apps/wallet/App.tsx index 53b6a74..f592b6d 100644 --- a/apps/wallet/App.tsx +++ b/apps/wallet/App.tsx @@ -6,6 +6,7 @@ import { useLocales } from "expo-localization"; import { StatusBar } from "expo-status-bar"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { type PoiProfile } from "./src/epc/poi"; import { type Payee } from "./src/epc/request"; import { readOpenedLink, type OpenedRequest } from "./src/epc/scan"; import { resolveLocale, type Language } from "./src/i18n"; @@ -42,6 +43,7 @@ import { HistoryScreen } from "./src/ui/HistoryScreen"; import { type Tab, TabBar } from "./src/ui/kit"; import { PayeeScreen } from "./src/ui/PayeeScreen"; import { PayeesScreen } from "./src/ui/PayeesScreen"; +import { PoiScreen } from "./src/ui/PoiScreen"; import { RequestScreen } from "./src/ui/RequestScreen"; import { ScanScreen } from "./src/ui/ScanScreen"; import { SettingsScreen } from "./src/ui/SettingsScreen"; @@ -62,6 +64,7 @@ type Origin = "tab" | "settings"; type Screen = | { name: "tab" } | { name: "settings" } + | { name: "poi" } | { name: "payees"; origin: Origin } | { name: "payee"; editing: number | null; origin: Origin; fromList: boolean }; @@ -152,6 +155,10 @@ export default function App() { (chosen: Language | null) => commit((current) => ({ ...current, language: chosen })), [commit], ); + const onPoiProfile = useCallback( + (profile: PoiProfile | null) => commit((current) => ({ ...current, poi: profile })), + [commit], + ); return ( @@ -162,6 +169,7 @@ export default function App() { deviceLanguage={deviceLanguage} onAppearance={onAppearance} onLanguage={onLanguage} + onPoiProfile={onPoiProfile} /> @@ -174,6 +182,7 @@ interface WalletProps { deviceLanguage: Language; onAppearance: (appearance: Preferences["appearance"]) => Promise; onLanguage: (language: Language | null) => Promise; + onPoiProfile: (profile: PoiProfile | null) => Promise; } function Wallet({ @@ -182,6 +191,7 @@ function Wallet({ deviceLanguage, onAppearance, onLanguage, + onPoiProfile, }: WalletProps) { const strings = useStrings(); const scheme = useScheme(); @@ -283,7 +293,8 @@ function Wallet({ // The system back gesture or button on Android leaves a screen opened over // the tabs the way its own back link does. On the tabs it keeps its meaning, - // and the payee form handles it itself, since Cancel waits while it writes. + // and the payee and EN 18184 forms handle it themselves, since Cancel waits + // while they write. useEffect(() => { if (screen.name !== "settings" && screen.name !== "payees") return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -324,6 +335,14 @@ function Wallet({ }, [commitBook], ); + // Saved or turned off, the form returns to the settings it was opened from. + const onSavePoi = useCallback( + async (profile: PoiProfile | null) => { + await onPoiProfile(profile); + setScreen((current) => (current.name === "poi" ? { name: "settings" } : current)); + }, + [onPoiProfile], + ); const onUsePayee = useCallback( (index: number) => commitBook((current) => setActivePayee(current, index)), [commitBook], @@ -453,6 +472,14 @@ function Wallet({ notice={book.loadFailed ? strings.payee.readFailed : null} /> + ) : screen.name === "poi" ? ( + + setScreen({ name: "settings" })} + /> + ) : screen.name === "payees" ? ( choosePayee("settings")} + onPoi={() => setScreen({ name: "poi" })} onExport={onExport} exportBlocked={exportBlocked} preferences={preferences} @@ -504,6 +532,7 @@ function Wallet({ } onSettings={() => setScreen({ name: "settings" })} onKeep={onKeep} + poiProfile={preferences.poi} /> {tab === "pay" ? ( diff --git a/apps/wallet/README.md b/apps/wallet/README.md index 455dac8..790db81 100644 --- a/apps/wallet/README.md +++ b/apps/wallet/README.md @@ -19,7 +19,8 @@ the payees, the appearance, the language and the file that moves the data to ano wallet speaks English, German, French, Spanish, Italian, Dutch and Polish, and follows the device language until one is chosen by hand. The screens follow the system appearance, light or dark, or the one chosen in the settings, and a native build shows the logo on a splash screen while the -settings are read. EN 18184 codes are not supported yet; see the checklist on the tracking issue. +settings are read. EN 18184 codes are read, and built once the settings hold the framework +details they need; see [EN 18184 codes](#en-18184-codes). ## Languages and appearance @@ -58,7 +59,8 @@ restarts, so a failed read never turns into a lost list. Settings offers an export and an import. The export writes the payees and the kept requests to one JSON file and hands it to the share sheet of the operating system, so where it goes is the -user's choice; the wallet sends nothing itself. The file names its format and a version number, +user's choice; the wallet sends nothing itself. The appearance, the language and the EN 18184 +details are settings of the device and stay out of the file. The file names its format and a version number, so a later shape of the file can still read an earlier export, and an export written by a newer wallet is refused with a message rather than misread. @@ -207,6 +209,67 @@ The app does not register `payto` with the operating system, so a tapped payto l open it. The handoff itself opens a payto URI, so a wallet registered for the scheme would be offered its own handoff. +## EN 18184 codes + +EN 18184:2025, published from EPC024-22, is the point-of-interaction format: a code is an https +URL rather than the line-based EPC069-12 payload. + +``` +https:///1///? +``` + +The domain belongs to an MSCT interoperability framework, the provider ID routes the payment to +the payee's MSCT service provider and the payload names its issuer by another ID. The framework +issues all three, and nothing in the code can be looked up to find them, so the wallet builds no +EN 18184 code until they are entered under Settings, EN 18184 codes. The form checks them with the +encoder: a bare domain name, not an IP address in any spelling and not an internationalised +(punycode) name, which URL parsers read differently, and two IDs of 3 letters or digits. From +then on the request screen offers a choice of format, EPC QR or EN 18184, and for EN 18184 a +choice of an instant or a standard transfer. The code is built with the payload profile that carries all data in clear, in +the person-to-person context `p`, and uses the parameter names of the Euvena profile v1 that +`@euvena/qr` documents. + +What changes for the payee against an EPC069-12 code: + +| | EPC069-12 | EN 18184 | +| --- | --- | --- | +| Read by | Most European banking apps | Apps in an EN 18184 framework | +| Amount | Optional; the payer can decide | Required | +| Remittance | Text up to 140 characters, or a creditor reference | Text or a structured reference, up to 35 characters | +| BIC | Carried when the payee has one | Not carried; the format has no element for it | +| Transfer | Not stated | Instant or standard, as chosen | +| Symbol | Level M, version 13 at most, as EPC069-12 requires | Level M; EPC024-22 names ISO/IEC 18004 and fixes no version, so up to 40 | + +A kept EN 18184 request goes into the history like any other and a shared one travels as its own +URL, since that is already the request. Turning the codes off in the settings brings the request +screen back to EPC069-12 only; the draft keeps what was picked for when they are set up again. + +EPC024-22 leaves the query parameter names to the payload issuer, so the wallet reads the names +of the Euvena profile v1 only. A conformant code from an issuer that chose other names is refused +as an address the wallet cannot read as an EN 18184 code. + +On the paying side a scanned or pasted `https://` address goes to the EN 18184 decoder, with the +scheme and host compared without case, since EPC024-22 writes its examples in capitals. A code with +all data in clear gets the same review as an EPC069-12 code, plus the transfer the payee asks for, +any trade name, reference party and merchant category, the payment context, and the framework, +provider and issuer the address names. A phone camera would have opened that address; the wallet +reads it on the device and opens nothing, and the review says so. When the payee asks for an +instant transfer, the handoff says so too, because neither the payto link nor a transfer form field +carries it. + +Refused, as a whole: + +| Code | Why | +| --- | --- | +| Token or proxy profile | The token or proxy stands for payee data that only the payee's provider can resolve, over its own network | +| A currency other than euro, compared without case | SEPA transfers are made in euro, and the handoff states a euro amount | +| Any field that fails a check, or any parameter given twice | Named by element, never quoted, as for EPC069-12 | +| An https address without the EN 18184 structure or the Euvena profile v1 names, served from an IP address or a punycode host, or with credentials, a port or a fragment | The wallet cannot read it as an EN 18184 code, and opens nothing | + +The codec parses the URL with the global `URL`. React Native's own `URL` is not the WHATWG one and +cuts a value at a second `=`, but Expo's runtime replaces it with a WHATWG implementation, against +which the codec's EN 18184 tests pass unchanged. + ## Running it From the repository root: @@ -238,10 +301,10 @@ pnpm --filter @euvena/wallet build # bundles the JS, no native toolchain requi | --- | --- | | `App.tsx` | Root component, loads the payee settings, opens incoming links, holds the tab bar and switches between the screens | | `plugins/` | Config plugin that keeps a restored Android activity from reopening its launch link | -| `src/epc/` | Form state to EPC069-12 payload, the link form of a request, plus the display formatting | +| `src/epc/` | Form state to an EPC069-12 payload or an EN 18184 URL (`poi.ts`), the link form of a request, plus the display formatting | | `src/i18n/` | The typed dictionary shape, one file per language, language resolution and number and date formatting | | `src/qr/` | QR symbol construction and its SVG path | -| `src/settings/` | Payee settings, the appearance and language preferences, the request history and the data file that moves them, on-device only | +| `src/settings/` | Payee settings, the appearance, language and EN 18184 preferences, the request history and the data file that moves them, on-device only | | `src/ui/` | Screens, the building blocks they share (`kit.tsx`), the palette for both appearances (`theme.ts`), the QR view and the file share and pick (`dataFile.ts`) | | `metro.config.js` | Workspace-aware resolver so `packages/*` resolve and hot-reload | | `test/` | Plain-TypeScript tests; the React Native surface is covered by typecheck and lint | diff --git a/apps/wallet/src/epc/link.ts b/apps/wallet/src/epc/link.ts index b3041fb..21938a1 100644 --- a/apps/wallet/src/epc/link.ts +++ b/apps/wallet/src/epc/link.ts @@ -19,7 +19,7 @@ import { EpcQrError, decodeEpcQr, type EpcQrData } from "@euvena/qr"; import { type Dictionary, type Rejection } from "../i18n"; -import { summarizeRequest } from "./request"; +import { summarizeRequest, type PaymentCode } from "./request"; /** URI scheme of a shared request. */ export const REQUEST_LINK_SCHEME = "euvena"; @@ -34,7 +34,7 @@ export const REQUEST_LINK_ACTION = "request"; export const REQUEST_LINK_PARAM = "epc"; export type ParsedRequestLink = - | { ok: true; payload: string; data: EpcQrData } + | { ok: true; format: "epc069"; payload: string; data: EpcQrData } | { ok: false; reason: Rejection }; const NOT_A_REQUEST_LINK: Rejection = { code: "linkNot" }; @@ -113,7 +113,7 @@ export function parseRequestLink(link: string): ParsedRequestLink { } try { - return { ok: true, payload, data: decodeEpcQr(payload).data }; + return { ok: true, format: "epc069", payload, data: decodeEpcQr(payload).data }; } catch (error) { if (error instanceof EpcQrError) { // The codec's message can repeat the input it rejected, and a link's @@ -149,14 +149,11 @@ function findParameter(query: string, name: string): string | undefined { * * The summary is for the person reading the message and the link is what * carries the request; both are built from the decoded payload, so neither can - * drift from the code. + * drift from the code. An EN 18184 code is a URL already, so it travels as + * itself. */ -export function buildShareMessage( - data: EpcQrData, - payload: string, - strings: Dictionary, - tag: string, -): string { - const lines = summarizeRequest(data, strings, tag).map((row) => `${row.label}: ${row.value}`); - return `${lines.join("\n")}\n\n${buildRequestLink(payload)}`; +export function buildShareMessage(code: PaymentCode, strings: Dictionary, tag: string): string { + const lines = summarizeRequest(code, strings, tag).map((row) => `${row.label}: ${row.value}`); + const link = code.format === "epc069" ? buildRequestLink(code.payload) : code.payload; + return `${lines.join("\n")}\n\n${link}`; } diff --git a/apps/wallet/src/epc/payto.ts b/apps/wallet/src/epc/payto.ts index feba667..599e151 100644 --- a/apps/wallet/src/epc/payto.ts +++ b/apps/wallet/src/epc/payto.ts @@ -23,7 +23,7 @@ * code likewise has no generic option and stays visible in the review only. */ -import { isValidAmountString, type EncodeEpcQrOptions, type EpcQrData } from "@euvena/qr"; +import { isValidAmountString, type EncodeEpcQrOptions } from "@euvena/qr"; import { type Rejection } from "../i18n"; @@ -33,7 +33,20 @@ export const PAYTO_SCHEME = "payto"; /** The only payment target type the wallet reads: a SEPA account by IBAN. */ const IBAN_TARGET = "iban"; -export function buildPaytoUri(data: EpcQrData): string { +/** + * What a handoff carries, which a code of either format can say. The amount + * is in euro: both readers refuse any other currency. + */ +export interface TransferDetails { + name: string; + iban: string; + bic?: string | undefined; + amount?: string | undefined; + reference?: string | undefined; + text?: string | undefined; +} + +export function buildPaytoUri(data: TransferDetails): string { const path = data.bic === undefined ? encodeURIComponent(data.iban) @@ -72,7 +85,7 @@ export interface HandoffField { * without display grouping and the amount as the codec's canonical string, * because bank forms are filled field by field and reject decoration. */ -export function handoffFields(data: EpcQrData): HandoffField[] { +export function handoffFields(data: TransferDetails): HandoffField[] { const fields: HandoffField[] = [ { key: "payee", value: data.name }, { key: "iban", value: data.iban }, diff --git a/apps/wallet/src/epc/poi.ts b/apps/wallet/src/epc/poi.ts new file mode 100644 index 0000000..0c46972 --- /dev/null +++ b/apps/wallet/src/epc/poi.ts @@ -0,0 +1,269 @@ +/** + * Payment requests in the point-of-interaction format of EN 18184:2025, the + * standard published from EPC024-22: an https URL instead of the line-based + * EPC069-12 payload. + * + * The URL names an MSCT interoperability framework by its domain, the payee's + * MSCT service provider by a 3-character ID the framework issues, and the + * payload issuer by another. None of the three can be invented, so the wallet + * only builds these codes from a profile the user enters in the settings, and + * builds none while there is no profile. + * + * Of the three payee-presented payload profiles, only the one with all data in + * clear names the account to be paid. A token or a proxy stands for data that + * only the payee's service provider can resolve, over its own network, so the + * wallet refuses those codes rather than opening their address: it reads + * everything on the device and contacts nobody. + * + * As on the EPC069-12 side, rejection reasons name what failed and never quote + * the codec, whose messages can repeat the value they rejected. + */ + +import { + MsctQrError, + decodeMsctQr, + encodeMsctPayeeClear, + encodeMsctPayeeToken, + formatAmount, + type MsctContext, + type MsctInstrument, + type MsctIssue, +} from "@euvena/qr"; + +import { type ElementKey, type Rejection } from "../i18n"; + +/** What a framework issues to take part: where codes point and who issues them. */ +export interface PoiProfile { + /** Domain of the MSCT interoperability framework or scheme, a bare host name. */ + domain: string; + /** The payee's MSCT service provider ID, 3 alphanumeric characters. */ + providerId: string; + /** The payload issuer ID, 3 alphanumeric characters. */ + issuer: string; +} + +export type PoiProfileField = keyof PoiProfile; + +export const EMPTY_POI_PROFILE: PoiProfile = { + domain: "", + providerId: "", + issuer: "", +}; + +/** + * The payment context the wallet puts in its codes: person to person. A + * request built here is one person asking another to pay; the merchant + * contexts make a trade name and a merchant category mandatory, which a + * wallet payee does not have. + */ +export const POI_CONTEXT: MsctContext = "p"; + +/** The only currency a SEPA credit transfer is made in. */ +const EURO = "EUR"; + +/** What an EN 18184 code with all data in clear says, as a scanner reads it. */ +export interface PoiRequest { + domain: string; + providerId: string; + context: MsctContext; + issuer: string; + /** The payee as account holder. */ + name: string; + /** The merchant's trade name, mandatory for merchants. */ + tradeName?: string; + /** Who the payee collects for, when that is someone else. */ + referencePartyName?: string; + referencePartyTradeName?: string; + iban: string; + /** Merchant category code, 4 digits. */ + mcc?: string; + /** Whether the payee asks for an instant or a standard transfer. */ + instrument: MsctInstrument; + purpose?: string; + /** Structured remittance information. */ + reference?: string; + /** Unstructured remittance information, named as the EPC069-12 side names it. */ + text?: string; + /** Euro, always present: EN 18184 has no open amount. */ + amount: string; +} + +export type ReadPoiResult = + { ok: true; payload: string; data: PoiRequest } | { ok: false; reason: Rejection }; + +/** Trims the profile the way it is stored. Host names do not depend on case. */ +export function normalizePoiProfile(profile: PoiProfile): PoiProfile { + return { + domain: profile.domain.trim().toLowerCase(), + providerId: profile.providerId.trim(), + issuer: profile.issuer.trim(), + }; +} + +/** Values the codec accepts, so each field is checked on its own. */ +const PROBE: PoiProfile = { + domain: "example.org", + providerId: "AAA", + issuer: "AAA", +}; + +const PROFILE_FIELDS: readonly PoiProfileField[] = ["domain", "providerId", "issuer"]; + +/** + * The profile fields the encoder refuses, in form order. + * + * The encoder is the judge, as validatePayee makes it for the payee, so a + * profile that saves always builds. It stops at the first field it faults, so + * each field is tried beside values it is known to accept. + */ +export function poiProfileIssues(profile: PoiProfile): PoiProfileField[] { + const normalized = normalizePoiProfile(profile); + return PROFILE_FIELDS.filter((field) => !encodes({ ...PROBE, [field]: normalized[field] })); +} + +function encodes(profile: PoiProfile): boolean { + try { + encodeMsctPayeeToken({ ...profile, context: POI_CONTEXT, token: "x" }); + return true; + } catch (error) { + if (error instanceof MsctQrError) return false; + throw error; + } +} + +export interface PoiRequestFields { + name: string; + iban: string; + /** A valid amount string, which EN 18184 requires. */ + amount: string; + instrument: MsctInstrument; + reference?: string; + text?: string; +} + +/** + * Builds the URL of a request. The payee and remittance map onto the profile + * with all data in clear; the unstructured text is what EPC024-22 calls the + * unstructured remittance information. + * + * @throws MsctQrError on any field the codec refuses. + */ +export function encodePoiRequest(profile: PoiProfile, fields: PoiRequestFields): string { + return encodeMsctPayeeClear({ + ...normalizePoiProfile(profile), + context: POI_CONTEXT, + name: fields.name, + iban: fields.iban, + instrument: fields.instrument, + currency: EURO, + amount: fields.amount, + ...(fields.reference === undefined ? {} : { reference: fields.reference }), + ...(fields.text === undefined ? {} : { remittance: fields.text }), + }); +} + +/** + * Reads an https URL as an EN 18184 payee-presented code. + * + * Anything that is not shaped like one is a web address and is refused as + * such; nothing is fetched to find out more. The codec runs leniently only to + * learn which fields failed: any issue still refuses the whole code, as + * strict mode would. + */ +export function readPoiRequest(input: string): ReadPoiResult { + let decoded: ReturnType; + try { + decoded = decodeMsctQr(input, { presenter: "payee", strict: false }); + } catch (error) { + if (error instanceof MsctQrError) return { ok: false, reason: { code: "poiNot" } }; + throw error; + } + + const { data, issues } = decoded; + if (issues.length > 0) { + return { + ok: false, + reason: { code: "poiInvalid", elements: poiElementKeys(issues) }, + }; + } + if (data.kind === "payee-token" || data.kind === "payee-proxy") { + return { ok: false, reason: { code: "poiNeedsProvider" } }; + } + if (data.kind !== "payee-clear") return { ok: false, reason: { code: "poiNot" } }; + // A lenient decode reports a missing instrument or amount as an issue, so + // these are present here; the checks keep the types honest. + if (data.instrument === undefined || data.amount === undefined) { + return { ok: false, reason: { code: "poiInvalid", elements: [] } }; + } + // The codec accepts any currency code, in either case. SEPA transfers are + // made in euro, and the handoff builds a euro amount, so anything else + // stops here. + if (data.currency?.toUpperCase() !== EURO) { + return { ok: false, reason: { code: "poiNotEuro" } }; + } + + const request: PoiRequest = { + domain: data.domain, + providerId: data.providerId, + // Checked against the payee contexts by the codec, which reported no issue. + context: data.context as MsctContext, + issuer: data.issuer, + name: data.name, + iban: data.iban, + instrument: data.instrument, + // The same number in the codec's canonical form, as the EPC069-12 side + // carries it, so "01.00" is copied and handed off as "1". + amount: formatAmount(Number(data.amount)), + ...(data.tradeName === undefined ? {} : { tradeName: data.tradeName }), + ...(data.referencePartyName === undefined + ? {} + : { referencePartyName: data.referencePartyName }), + ...(data.referencePartyTradeName === undefined + ? {} + : { referencePartyTradeName: data.referencePartyTradeName }), + ...(data.mcc === undefined ? {} : { mcc: data.mcc }), + ...(data.purpose === undefined ? {} : { purpose: data.purpose }), + ...(data.reference === undefined ? {} : { reference: data.reference }), + ...(data.remittance === undefined ? {} : { text: data.remittance }), + }; + return { ok: true, payload: input, data: request }; +} + +/** The codec's EN 18184 field names, as the elements the wallet names. */ +const POI_ELEMENT_KEYS: ReadonlyMap = new Map( + Object.entries({ + version: "version", + domain: "routing", + providerId: "routing", + context: "routing", + issuer: "routing", + name: "name", + tradeName: "tradeName", + referencePartyName: "referenceParty", + referencePartyTradeName: "referenceParty", + referencePartyProxy: "referenceParty", + iban: "iban", + mcc: "category", + instrument: "instrument", + purpose: "purpose", + reference: "reference", + remittance: "text", + // The same two fields, as the codec names them when it checks the + // parameters themselves, such as one given twice. + referenceStructured: "reference", + remittanceUnstructured: "text", + currency: "currency", + amount: "amount", + payload: "payload", + } satisfies Record), +); + +/** The distinct elements the issues name, in order. Unknown fields read as "other". */ +export function poiElementKeys(issues: readonly MsctIssue[]): ElementKey[] { + const keys: ElementKey[] = []; + for (const issue of issues) { + const key = POI_ELEMENT_KEYS.get(issue.field) ?? "other"; + if (!keys.includes(key)) keys.push(key); + } + return keys; +} diff --git a/apps/wallet/src/epc/request.ts b/apps/wallet/src/epc/request.ts index 6a09e71..4ddf917 100644 --- a/apps/wallet/src/epc/request.ts +++ b/apps/wallet/src/epc/request.ts @@ -1,5 +1,6 @@ /** - * Turns the request form into an EPC069-12 payload. + * Turns the request form into a payment code: an EPC069-12 payload, or the + * URL of an EN 18184 code when a profile for one is set up. * * Everything here is plain TypeScript so it can be exercised without a React * Native runtime. The screen owns the form state; this module owns the mapping @@ -8,14 +9,18 @@ import { EpcQrError, + MsctQrError, decodeEpcQr, isValidAmountString, type EpcQrData, type EpcQrIssue, + type MsctInstrument, + type MsctIssue, encodeEpcQr, } from "@euvena/qr"; import { formatMoney, type Dictionary } from "../i18n"; +import { encodePoiRequest, readPoiRequest, type PoiProfile, type PoiRequest } from "./poi"; /** * EPC069-12 element 10 (structured creditor reference) and element 11 @@ -32,20 +37,43 @@ export interface Payee { bic: string; } +/** + * Which standard a code follows. EPC069-12 is what banking apps scan today; + * EN 18184 is the point-of-interaction format, read by apps in an MSCT + * interoperability framework. + */ +export type CodeFormat = "epc069" | "en18184"; + export interface RequestForm { /** As typed, so "12,30" and " 12.30 " are both accepted. */ amount: string; remittanceKind: RemittanceKind; remittance: string; + /** Only followed while an EN 18184 profile is set up. */ + format: CodeFormat; + /** Instant or standard transfer, which only an EN 18184 code can ask for. */ + instrument: MsctInstrument; } -export type BuildRequestResult = - | { ok: true; payload: string; data: EpcQrData } - | { ok: false; issues: EpcQrIssue[] }; +/** + * A code and the values a scanner reads out of it. The payload is the text + * the code carries: an EPC069-12 payload or an EN 18184 URL. + */ +export type PaymentCode = + | { format: "epc069"; payload: string; data: EpcQrData } + | { format: "en18184"; payload: string; data: PoiRequest }; + +export type BuildRequestResult = ({ ok: true } & PaymentCode) | { ok: false; issues: EpcQrIssue[] }; export const EMPTY_PAYEE: Payee = { name: "", iban: "", bic: "" }; -export const EMPTY_FORM: RequestForm = { amount: "", remittanceKind: "text", remittance: "" }; +export const EMPTY_FORM: RequestForm = { + amount: "", + remittanceKind: "text", + remittance: "", + format: "epc069", + instrument: "INST", +}; export type PayeeField = keyof Payee; @@ -146,13 +174,18 @@ export function normalizeAmountInput(input: string): string { * Builds the payload, then decodes it so the screen can display the values a * scanner will actually read rather than the values that were typed. * - * An empty amount is not an error: EPC069-12 keeps element 8 optional so the - * payer can enter the amount in their own banking app. + * An empty amount is not an error for EPC069-12, which keeps element 8 + * optional so the payer can enter the amount in their own banking app. An + * EN 18184 code has no open amount, so there it is one. + * + * The EN 18184 format is followed only with a profile to build it from; the + * screen offers it only then. */ export function buildPaymentRequest( payee: Payee, form: RequestForm, strings: Dictionary, + profile: PoiProfile | null = null, ): BuildRequestResult { const issues: EpcQrIssue[] = []; @@ -167,13 +200,23 @@ export function buildPaymentRequest( // any other problem, so the amount is checked here and kept out of the call. const amount = normalizeAmountInput(form.amount); const hasAmount = amount !== ""; + const poi = form.format === "en18184"; if (hasAmount && !isValidAmountString(amount)) { issues.push({ element: "amount", message: strings.payeeIssues.amountRange }); + } else if (poi && !hasAmount) { + issues.push({ element: "amount", message: strings.payeeIssues.amountRequired }); + } + if (poi && profile === null) { + issues.push({ element: "routing", message: strings.payeeIssues.poiProfile }); } const remittance = form.remittance.trim(); if (issues.length > 0) return { ok: false, issues }; + if (poi && profile !== null) { + return buildPoiCode(profile, { name, iban, bic }, amount, form, remittance, strings); + } + let remittanceElement: { reference: string } | { text: string } | Record = {}; if (remittance !== "") { remittanceElement = @@ -188,7 +231,7 @@ export function buildPaymentRequest( ...(hasAmount ? { amount } : {}), ...remittanceElement, }); - return { ok: true, payload, data: decodeEpcQr(payload).data }; + return { ok: true, format: "epc069", payload, data: decodeEpcQr(payload).data }; } catch (error) { if (error instanceof EpcQrError) { const normalized = { name, iban, bic }; @@ -201,6 +244,72 @@ export function buildPaymentRequest( } } +/** + * The EN 18184 side of buildPaymentRequest, called once the payee and the + * amount have passed. A BIC is not carried: EPC024-22 has no element for it. + */ +function buildPoiCode( + profile: PoiProfile, + payee: Payee, + amount: string, + form: RequestForm, + remittance: string, + strings: Dictionary, +): BuildRequestResult { + const reference = form.remittanceKind === "reference"; + let url: string; + try { + url = encodePoiRequest(profile, { + name: payee.name, + iban: payee.iban, + amount, + instrument: form.instrument, + ...(remittance === "" ? {} : reference ? { reference: remittance } : { text: remittance }), + }); + } catch (error) { + if (error instanceof MsctQrError) { + return { + ok: false, + issues: error.issues.map((issue) => describePoiIssue(issue, payee, strings)), + }; + } + throw error; + } + // Read back as a scanned code is read, so the screen shows what a scanner + // takes out of the URL. A URL the encoder built always reads back. + const read = readPoiRequest(url); + if (!read.ok) { + return { ok: false, issues: [{ element: "payload", message: strings.payeeIssues.unencodable }] }; + } + return { ok: true, format: "en18184", payload: url, data: read.data }; +} + +/** + * Words an EN 18184 encoder issue, as describeFormIssue does for EPC069-12. + * Issues are reported against the element names of the form, so the + * unstructured remittance is "text" here too. + */ +function describePoiIssue(issue: MsctIssue, payee: Payee, strings: Dictionary): EpcQrIssue { + switch (issue.field) { + case "name": + case "iban": + return { element: issue.field, message: describePayeeIssue(issue.field, payee, strings) }; + case "amount": + return { element: "amount", message: strings.payeeIssues.amountRange }; + case "remittance": + return { element: "text", message: strings.payeeIssues.poiTextShape }; + case "reference": + return { element: "reference", message: strings.payeeIssues.poiReferenceShape }; + case "domain": + case "providerId": + case "issuer": + case "context": + return { element: "routing", message: strings.payeeIssues.poiProfile }; + default: + return { element: issue.field, message: strings.payeeIssues.unencodable }; + } +} + export interface RequestRow { label: string; value: string; @@ -217,7 +326,13 @@ export interface RequestRow { * purpose or information element of its own, but the scan side reviews codes * from anywhere, and a review that drops elements is not a review. */ -export function summarizeRequest(data: EpcQrData, strings: Dictionary, tag: string): RequestRow[] { +export function summarizeRequest(code: PaymentCode, strings: Dictionary, tag: string): RequestRow[] { + return code.format === "epc069" + ? summarizeEpcRequest(code.data, strings, tag) + : summarizePoiRequest(code.data, strings, tag); +} + +function summarizeEpcRequest(data: EpcQrData, strings: Dictionary, tag: string): RequestRow[] { const labels = strings.rows; const rows: RequestRow[] = [ { label: labels.payee, value: data.name }, @@ -237,6 +352,43 @@ export function summarizeRequest(data: EpcQrData, strings: Dictionary, tag: stri return rows; } +/** + * The rows of an EN 18184 code. Beside what an EPC069-12 code carries, it + * names the kind of transfer the payee asks for, who the payee trades as or + * collects for, and where the URL points: the framework and the provider are + * what a phone camera would open, so they are part of what the payer checks. + */ +function summarizePoiRequest(data: PoiRequest, strings: Dictionary, tag: string): RequestRow[] { + const labels = strings.rows; + const rows: RequestRow[] = [{ label: labels.payee, value: data.name }]; + if (data.tradeName !== undefined) rows.push({ label: labels.tradeName, value: data.tradeName }); + if (data.referencePartyName !== undefined) { + rows.push({ label: labels.onBehalfOf, value: data.referencePartyName }); + } + if (data.referencePartyTradeName !== undefined) { + rows.push({ label: labels.onBehalfOfTrade, value: data.referencePartyTradeName }); + } + rows.push( + { label: labels.iban, value: formatIbanForDisplay(data.iban) }, + { label: labels.amount, value: formatMoney(data.amount, tag) }, + { + label: labels.transfer, + value: data.instrument === "INST" ? labels.instant : labels.standard, + }, + ); + if (data.purpose !== undefined) rows.push({ label: labels.purpose, value: data.purpose }); + if (data.reference !== undefined) rows.push({ label: labels.reference, value: data.reference }); + if (data.text !== undefined) rows.push({ label: labels.text, value: data.text }); + if (data.mcc !== undefined) rows.push({ label: labels.category, value: data.mcc }); + rows.push( + { label: labels.context, value: labels.contexts[data.context] }, + { label: labels.framework, value: data.domain }, + { label: labels.provider, value: data.providerId }, + { label: labels.issuer, value: data.issuer }, + ); + return rows; +} + /** Groups an IBAN into blocks of four, the presentation format of ISO 13616. */ export function formatIbanForDisplay(iban: string): string { return iban.replace(/(.{4})/g, "$1 ").trim(); diff --git a/apps/wallet/src/epc/scan.ts b/apps/wallet/src/epc/scan.ts index b82a872..5607586 100644 --- a/apps/wallet/src/epc/scan.ts +++ b/apps/wallet/src/epc/scan.ts @@ -2,26 +2,26 @@ * Reads payer-side input, a scanned QR code, pasted text or a link the app was * opened with, back into a payment request. * - * Three shapes arrive here: the EPC069-12 payload itself, which is what a - * displayed or printed code carries, the shared-link form from ./link and an - * RFC 8905 payto URI from ./payto. All of them end at the same codec in strict - * mode, so nothing scanned, pasted or opened can present values that a code - * could not carry. + * Four shapes arrive here: the EPC069-12 payload itself, which is what a + * displayed or printed code carries, the https URL of an EN 18184 code from + * ./poi, the shared-link form from ./link and an RFC 8905 payto URI from + * ./payto. Each ends at its codec with every check applied, so nothing + * scanned, pasted or opened can present values that a code could not carry. * * Rejection reasons are fixed sentences that name the element that failed and * nothing else. The codec's own messages can quote the value they rejected, * and a scanned code is someone else's writing, so they are never shown. */ -import { EpcQrError, decodeEpcQr, encodeEpcQr, type EpcQrData } from "@euvena/qr"; +import { EpcQrError, decodeEpcQr, encodeEpcQr } from "@euvena/qr"; import { elementKeys, sameRejection, type Rejection } from "../i18n"; import { REQUEST_LINK_SCHEME, parseRequestLink } from "./link"; import { PAYTO_SCHEME, parsePaytoUri } from "./payto"; +import { readPoiRequest } from "./poi"; +import { type PaymentCode } from "./request"; -export type ReadRequestResult = - | { ok: true; payload: string; data: EpcQrData } - | { ok: false; reason: Rejection }; +export type ReadRequestResult = ({ ok: true } & PaymentCode) | { ok: false; reason: Rejection }; /** A request the app was opened with, numbered in order of arrival. */ export interface OpenedRequest { @@ -40,6 +40,12 @@ const OWN_SCHEME_PREFIX = `${REQUEST_LINK_SCHEME}:`; /** How a payto URI begins, compared without case. */ const PAYTO_PREFIX = `${PAYTO_SCHEME}://`; +/** + * How an EN 18184 code begins, compared without case: EPC024-22 writes its + * examples in capitals, which a QR code stores more compactly. + */ +const POI_PREFIX = "https://"; + const PAYTO_TOO_LONG: Rejection = { code: "paytoTooLong" }; /** @@ -58,7 +64,7 @@ const PAYTO_TOO_LONG: Rejection = { code: "paytoTooLong" }; export function readPaymentRequest(input: string): ReadRequestResult { if (input.startsWith("BCD")) { try { - return { ok: true, payload: input, data: decodeEpcQr(input).data }; + return { ok: true, format: "epc069", payload: input, data: decodeEpcQr(input).data }; } catch (error) { if (error instanceof EpcQrError) { return { ok: false, reason: rejectionOf(error, "codeInvalid") }; @@ -72,6 +78,10 @@ export function readPaymentRequest(input: string): ReadRequestResult { if (trimmed.slice(0, PAYTO_PREFIX.length).toLowerCase() === PAYTO_PREFIX) { return readPaytoRequest(trimmed); } + if (trimmed.slice(0, POI_PREFIX.length).toLowerCase() === POI_PREFIX) { + const read = readPoiRequest(trimmed); + return read.ok ? { ...read, format: "en18184" } : read; + } if (SCHEME_SHAPED.test(trimmed)) return parseRequestLink(trimmed); return { ok: false, reason: NOT_A_PAYMENT_INPUT }; @@ -91,7 +101,7 @@ function readPaytoRequest(uri: string): ReadRequestResult { if (!parsed.ok) return parsed; try { const payload = encodeEpcQr(parsed.request); - return { ok: true, payload, data: decodeEpcQr(payload).data }; + return { ok: true, format: "epc069", payload, data: decodeEpcQr(payload).data }; } catch (error) { if (error instanceof EpcQrError) { if (error.issues.some((issue) => issue.element === "payload")) { diff --git a/apps/wallet/src/i18n/de.ts b/apps/wallet/src/i18n/de.ts index c6f1cd7..6a04e1f 100644 --- a/apps/wallet/src/i18n/de.ts +++ b/apps/wallet/src/i18n/de.ts @@ -15,6 +15,12 @@ export const de: Dictionary = { text: "der Verwendungszweck", information: "die Hinweiszeile", payload: "der Gesamtaufbau", + tradeName: "der Handelsname", + referenceParty: "die vertretene Partei", + instrument: "die Überweisungsart", + currency: "die Währung", + category: "die Händlerkategorie", + routing: "die Routing-Angaben", other: "ein Element", }, rows: { @@ -27,6 +33,24 @@ export const de: Dictionary = { text: "Verwendungszweck", information: "Hinweis", payerDecides: "vom Zahler eingegeben", + tradeName: "Handelsname", + onBehalfOf: "Im Auftrag von", + onBehalfOfTrade: "Im Auftrag von, Handelsname", + transfer: "Überweisung", + instant: "Echtzeit", + standard: "Standard", + category: "Händlerkategorie", + context: "Zahlungskontext", + contexts: { + m: "Im Geschäft", + e: "Online-Einkauf", + i: "Rechnung", + p: "Von Person zu Person", + w: "Webseite", + }, + framework: "Rahmenwerk", + provider: "Anbieter", + issuer: "Aussteller", }, common: { cancel: "Abbrechen", @@ -55,6 +79,11 @@ export const de: Dictionary = { requestTooLong: "Die Anforderung passt nicht in einen Code. Kürze den Text.", unencodable: "Die Anforderung konnte nicht kodiert werden.", inPayeeSettings: "(Empfängereinstellungen)", + amountRequired: "Ein EN-18184-Code enthält einen festen Betrag. Gib einen ein.", + poiTextShape: "Bis zu 35 sichtbare Zeichen in einer Zeile in einem EN-18184-Code", + poiReferenceShape: + "Bis zu 35 Zeichen; eine Referenz, die mit RF beginnt, muss ihre Prüfziffern bestehen", + poiProfile: "Prüfe die EN-18184-Einstellungen.", }, rejections: { empty: "Es gibt nichts zu lesen.", @@ -83,6 +112,13 @@ export const de: Dictionary = { linkNoRequest: "Der Link enthält keine Zahlungsanforderung.", linkDamaged: "Der Link ist beschädigt und kann nicht gelesen werden.", linkInvalid: "Der Link enthält keine gültige Zahlungsanforderung.", + poiNot: "Die App kann diese Adresse nicht als EN-18184-Zahlungscode lesen. Es wurde nichts geöffnet.", + poiInvalid: (elements) => + `Der EN-18184-Code ist keine gültige Zahlungsanforderung. Nicht bestanden: ${joinList(elements)}.`, + poiNoRequest: "Der EN-18184-Code enthält keine gültige Zahlungsanforderung.", + poiNeedsProvider: + "Dieser EN-18184-Code bezeichnet den Empfänger über einen Zahlungsdienstleister, und nur dessen App kann ihn auflösen. Euvena liest Codes, die die Empfängerdaten vollständig enthalten.", + poiNotEuro: "Der EN-18184-Code verlangt eine andere Währung als Euro.", }, request: { title: "Geld anfordern", @@ -110,6 +146,19 @@ export const de: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Bis zu 140 Zeichen Text", referenceHint: "Eine strukturierte Gläubigerreferenz, bis zu 35 Zeichen", + format: "Codeformat", + formatEpc: "EPC-QR", + formatPoi: "EN 18184", + formatEpcHint: "Wird von den meisten europäischen Banking-Apps gelesen.", + formatPoiHint: + "Für Apps in einem EN-18184-Rahmenwerk. Der Code ist eine Webadresse auf der Domain des Rahmenwerks und enthält einen festen Betrag.", + transfer: "Überweisung", + instant: "Echtzeit", + standard: "Standard", + enterAmount: "Betrag eingeben", + amountRequiredA11y: "Betrag in Euro, für einen EN-18184-Code erforderlich", + poiTextHint: "Bis zu 35 Zeichen Text", + poiReferenceHint: "Eine strukturierte Referenz, bis zu 35 Zeichen", }, composed: { shareTitle: "Zahlungsanforderung", @@ -124,6 +173,8 @@ export const de: Dictionary = { whatTheCodeSays: "Was der Code sagt", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 Version ${version}, UTF-8, ${bytes} von ${maxBytes} Bytes. QR-Version ${qrVersion} von ${maxQrVersion}, Fehlerkorrektur ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) Version 1, ${characters} Zeichen. QR-Version ${qrVersion}, Fehlerkorrektur ${correction}.`, shareFailed: "Die Anforderung konnte nicht geteilt werden.", renderFailed: "Der Code konnte nicht gezeichnet werden.", }, @@ -141,7 +192,7 @@ export const de: Dictionary = { "Die Kamera ist für diese App in den Systemeinstellungen ausgeschaltet. Einfügen unten funktioniert weiterhin.", pointCamera: "Richte die Kamera auf einen Zahlungs-QR-Code.", pasteLabel: "Oder eine Anforderung einfügen", - pastePlaceholder: "Ein euvena://- oder payto://-Link oder der Text eines Codes", + pastePlaceholder: "Ein euvena://-, payto://- oder https://-Link oder der Text eines Codes", readPasted: "Eingefügtes lesen", reviewHint: "Werte aus dem Code selbst. Prüfe Name und IBAN bei der Person, die um Zahlung bittet; der Code kann das nicht für dich tun.", @@ -159,6 +210,10 @@ export const de: Dictionary = { rejectionHint: "Eine Anforderung, die eine Prüfung nicht besteht, wird gar nicht angezeigt: ein teilweise gelesener Code könnte Geld auf das falsche Konto lenken.", tryAgain: "Erneut versuchen", + poiRead: + "Dieser EN-18184-Code ist eine Webadresse. Die App hat ihn auf diesem Gerät gelesen und nichts geöffnet.", + instantAsked: + "Der Empfänger bittet um eine Echtzeitüberweisung. Wähle sie in deiner Banking-App, wo sie angeboten wird.", }, history: { title: "Verlauf", @@ -252,9 +307,32 @@ export const de: Dictionary = { requestsCount: (n) => `${n} ${n === 1 ? "Anforderung" : "Anforderungen"}`, and: (a, b) => `${a} und ${b}`, shareDialog: "Daten der App speichern oder senden", + poi: "EN-18184-Codes", + poiOff: "Aus", + poiHint: + "Codes für den Point of Interaction mit den Angaben erstellen, die dir ein MSCT-Rahmenwerk zugewiesen hat.", about: "Über", aboutText: - "Euvena ist eine Referenz-App für EPC069-12-Zahlungscodes. Sie hat keine Konten und keinen Server und bewegt nie Geld: eine Anforderung wird an deine Banking-App übergeben.", + "Euvena ist eine Referenz-App für EPC069-12- und EN-18184-Zahlungscodes. Sie hat keine Konten und keinen Server und bewegt nie Geld: eine Anforderung wird an deine Banking-App übergeben.", + }, + poi: { + title: "EN-18184-Codes", + subtitle: + "Nur auf diesem Gerät gespeichert. Anforderungen lassen sich dann auch als EN-18184-Codes erstellen, nicht nur als EPC-QR-Codes.", + domain: "Domain des Rahmenwerks", + domainPlaceholder: "qr.example.org", + domainShape: "Nur den Hostnamen eingeben, ohne https:// und ohne Pfad", + providerId: "Anbieter-ID", + providerPlaceholder: "3 Buchstaben oder Ziffern", + providerShape: "Genau 3 Buchstaben oder Ziffern", + issuer: "Aussteller-ID", + issuerPlaceholder: "3 Buchstaben oder Ziffern", + issuerShape: "Genau 3 Buchstaben oder Ziffern", + explainer: + "Ein EN-18184-Code ist eine Webadresse auf der Domain eines MSCT-Interoperabilitätsrahmenwerks, die über die Anbieter-ID an deinen Zahlungsdienstleister geleitet wird. Verwende die Werte, die dir dein Rahmenwerk oder Anbieter gegeben hat: Codes mit anderen Werten verweisen auf eine Adresse, die nicht dir gehört.", + saveFailed: "Die Einstellungen konnten nicht auf diesem Gerät gespeichert werden.", + turnOff: "EN-18184-Codes ausschalten", + turningOff: "Wird ausgeschaltet", }, }; diff --git a/apps/wallet/src/i18n/dictionary.ts b/apps/wallet/src/i18n/dictionary.ts index 929cf70..b38b63d 100644 --- a/apps/wallet/src/i18n/dictionary.ts +++ b/apps/wallet/src/i18n/dictionary.ts @@ -9,6 +9,8 @@ * codec's detail as it is, the way an error code is kept. */ +import type { MsctContext } from "@euvena/qr"; + export const LANGUAGES = ["en", "de", "fr", "es", "it", "nl", "pl"] as const; export type Language = (typeof LANGUAGES)[number]; @@ -39,6 +41,17 @@ export type ElementKey = | "text" | "information" | "payload" + /** EN 18184 only: the merchant's trade name. */ + | "tradeName" + /** EN 18184 only: who the payee collects for. */ + | "referenceParty" + /** EN 18184 only: instant or standard transfer. */ + | "instrument" + | "currency" + /** EN 18184 only: the merchant category code. */ + | "category" + /** EN 18184 only: the framework, provider, issuer and payment context. */ + | "routing" /** An element the wallet has no name for. */ | "other"; @@ -63,7 +76,11 @@ export type RejectionCode = | "linkRetired" | "linkNoRequest" | "linkDamaged" - | "linkInvalid"; + | "linkInvalid" + | "poiNot" + | "poiInvalid" + | "poiNeedsProvider" + | "poiNotEuro"; /** Why a data file was refused. */ export type TransferRefusal = "notAnExport" | "newerExport" | "tooLarge" | "bookUnread"; @@ -83,6 +100,19 @@ export interface Dictionary { information: string; /** The amount row when the code leaves it to the payer. */ payerDecides: string; + /** EN 18184 rows. */ + tradeName: string; + onBehalfOf: string; + onBehalfOfTrade: string; + transfer: string; + instant: string; + standard: string; + category: string; + context: string; + contexts: Record; + framework: string; + provider: string; + issuer: string; }; common: { cancel: string; @@ -122,14 +152,26 @@ export interface Dictionary { unencodable: string; /** Appended to a payee issue listed under the request form. */ inPayeeSettings: string; + /** An EN 18184 code was chosen without an amount. */ + amountRequired: string; + /** EN 18184 remittance text is shorter than EPC069-12's. */ + poiTextShape: string; + poiReferenceShape: string; + /** The EN 18184 profile in the settings does not build. */ + poiProfile: string; }; /** Why an input was not a payment request. Invalid ones name their elements. */ - rejections: Record, string> & { + rejections: Record< + Exclude, + string + > & { codeInvalid: (elements: string[]) => string; paytoInvalid: (elements: string[]) => string; + poiInvalid: (elements: string[]) => string; /** A code or link the codec faulted without naming an element. */ codeNoRequest: string; paytoNoRequest: string; + poiNoRequest: string; }; request: { title: string; @@ -156,6 +198,20 @@ export interface Dictionary { referencePlaceholder: string; textHint: string; referenceHint: string; + /** The choice of code format, offered once an EN 18184 profile is set up. */ + format: string; + formatEpc: string; + formatPoi: string; + formatEpcHint: string; + formatPoiHint: string; + transfer: string; + instant: string; + standard: string; + /** The amount placeholder when the code needs one. */ + enterAmount: string; + amountRequiredA11y: string; + poiTextHint: string; + poiReferenceHint: string; }; composed: { shareTitle: string; @@ -177,6 +233,8 @@ export interface Dictionary { maxQrVersion: number; correction: string; }) => string; + /** "EN 18184 (EPC024-22) version 1, {n} characters. QR version {q}, error correction {ec}." */ + poiFigures: (values: { characters: number; qrVersion: number; correction: string }) => string; shareFailed: string; renderFailed: string; }; @@ -205,6 +263,10 @@ export interface Dictionary { nothingRead: string; rejectionHint: string; tryAgain: string; + /** Under the review of an EN 18184 code. */ + poiRead: string; + /** Beside the handoff, when the payee asks for an instant transfer. */ + instantAsked: string; }; history: { title: string; @@ -290,7 +352,28 @@ export interface Dictionary { /** Joins two counted halves: "{a} and {b}". */ and: (a: string, b: string) => string; shareDialog: string; + poi: string; + poiOff: string; + poiHint: string; about: string; aboutText: string; }; + /** The EN 18184 profile form. */ + poi: { + title: string; + subtitle: string; + domain: string; + domainPlaceholder: string; + domainShape: string; + providerId: string; + providerPlaceholder: string; + providerShape: string; + issuer: string; + issuerPlaceholder: string; + issuerShape: string; + explainer: string; + saveFailed: string; + turnOff: string; + turningOff: string; + }; } diff --git a/apps/wallet/src/i18n/en.ts b/apps/wallet/src/i18n/en.ts index 268f298..11e4d94 100644 --- a/apps/wallet/src/i18n/en.ts +++ b/apps/wallet/src/i18n/en.ts @@ -16,6 +16,12 @@ export const en: Dictionary = { text: "the remittance text", information: "the information line", payload: "the overall structure", + tradeName: "the trade name", + referenceParty: "the party collected for", + instrument: "the transfer type", + currency: "the currency", + category: "the merchant category", + routing: "the routing part", other: "an element", }, rows: { @@ -28,6 +34,24 @@ export const en: Dictionary = { text: "Text", information: "Information", payerDecides: "entered by the payer", + tradeName: "Trade name", + onBehalfOf: "On behalf of", + onBehalfOfTrade: "On behalf of, trade name", + transfer: "Transfer", + instant: "Instant", + standard: "Standard", + category: "Merchant category", + context: "Payment context", + contexts: { + m: "In store", + e: "Online purchase", + i: "Invoice", + p: "Person to person", + w: "Web page", + }, + framework: "Framework", + provider: "Provider", + issuer: "Issuer", }, common: { cancel: "Cancel", @@ -56,6 +80,11 @@ export const en: Dictionary = { requestTooLong: "The request does not fit a code. Shorten the text.", unencodable: "The request could not be encoded.", inPayeeSettings: "(payee settings)", + amountRequired: "An EN 18184 code carries a fixed amount. Enter one.", + poiTextShape: "Up to 35 visible characters on one line in an EN 18184 code", + poiReferenceShape: + "Up to 35 characters; a reference that starts with RF must have correct check digits", + poiProfile: "Check the EN 18184 settings.", }, rejections: { empty: "There is nothing to read.", @@ -84,6 +113,13 @@ export const en: Dictionary = { linkNoRequest: "The link carries no payment request.", linkDamaged: "The link is damaged and cannot be read.", linkInvalid: "The link does not carry a valid payment request.", + poiNot: "The wallet cannot read this address as an EN 18184 payment code. Nothing was opened.", + poiInvalid: (elements) => + `The EN 18184 code is not a valid payment request: ${joinList(elements)} failed the checks.`, + poiNoRequest: "The EN 18184 code does not carry a valid payment request.", + poiNeedsProvider: + "This EN 18184 code names the payee through a payment provider, and only that provider's app can look it up. Euvena reads codes that carry the payee's details in full.", + poiNotEuro: "The EN 18184 code asks for a currency other than euro.", }, request: { title: "Request money", @@ -111,6 +147,19 @@ export const en: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Up to 140 characters of text", referenceHint: "A structured creditor reference, up to 35 characters", + format: "Code format", + formatEpc: "EPC QR", + formatPoi: "EN 18184", + formatEpcHint: "Read by most European banking apps.", + formatPoiHint: + "For apps in an EN 18184 framework. The code is a web address on the framework's domain and carries a fixed amount.", + transfer: "Transfer", + instant: "Instant", + standard: "Standard", + enterAmount: "Enter an amount", + amountRequiredA11y: "Amount in euro, required for an EN 18184 code", + poiTextHint: "Up to 35 characters of text", + poiReferenceHint: "A structured reference, up to 35 characters", }, composed: { shareTitle: "Payment request", @@ -125,6 +174,8 @@ export const en: Dictionary = { whatTheCodeSays: "What the code says", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 version ${version}, UTF-8, ${bytes} of ${maxBytes} bytes. QR version ${qrVersion} of ${maxQrVersion}, error correction ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) version 1, ${characters} characters. QR version ${qrVersion}, error correction ${correction}.`, shareFailed: "The request could not be shared.", renderFailed: "The code could not be rendered.", }, @@ -141,7 +192,7 @@ export const en: Dictionary = { "The camera is switched off for this app in the system settings. Pasting below still works.", pointCamera: "Point the camera at a payment QR code.", pasteLabel: "Or paste a request", - pastePlaceholder: "A euvena:// or payto:// link, or the text of a code", + pastePlaceholder: "A euvena://, payto:// or https:// link, or the text of a code", readPasted: "Read what was pasted", reviewHint: "Values read from the code itself. Check the name and IBAN with whoever is asking to be paid; the code cannot do that for you.", @@ -159,6 +210,10 @@ export const en: Dictionary = { rejectionHint: "A request that fails a check is not shown at all: a partial reading could direct money to the wrong account.", tryAgain: "Try again", + poiRead: + "This EN 18184 code is a web address. The wallet read it on this device and opened nothing.", + instantAsked: + "The payee asks for an instant transfer. Choose instant in your banking app where it is offered.", }, history: { title: "History", @@ -250,9 +305,31 @@ export const en: Dictionary = { requestsCount: (n) => `${n} ${n === 1 ? "request" : "requests"}`, and: (a, b) => `${a} and ${b}`, shareDialog: "Save or send the wallet data", + poi: "EN 18184 codes", + poiOff: "Off", + poiHint: "Build point-of-interaction codes with the details an MSCT framework assigned to you.", about: "About", aboutText: - "Euvena is a reference wallet for EPC069-12 payment codes. It has no accounts and no backend, and it never moves money: a request is handed to your banking app.", + "Euvena is a reference wallet for EPC069-12 and EN 18184 payment codes. It has no accounts and no backend, and it never moves money: a request is handed to your banking app.", + }, + poi: { + title: "EN 18184 codes", + subtitle: + "Held on this device only. Requests can then be built as EN 18184 codes as well as EPC QR codes.", + domain: "Framework domain", + domainPlaceholder: "qr.example.org", + domainShape: "Enter the host name alone, without https:// or a path", + providerId: "Provider ID", + providerPlaceholder: "3 letters or digits", + providerShape: "Exactly 3 letters or digits", + issuer: "Issuer ID", + issuerPlaceholder: "3 letters or digits", + issuerShape: "Exactly 3 letters or digits", + explainer: + "An EN 18184 code is a web address on the domain of an MSCT interoperability framework, routed to your payment provider by its ID. Use the values your framework or provider gave you: codes built with other values point at an address that is not yours.", + saveFailed: "The settings could not be saved to this device.", + turnOff: "Turn off EN 18184 codes", + turningOff: "Turning off", }, }; diff --git a/apps/wallet/src/i18n/es.ts b/apps/wallet/src/i18n/es.ts index 1310f3b..0bc2d84 100644 --- a/apps/wallet/src/i18n/es.ts +++ b/apps/wallet/src/i18n/es.ts @@ -15,6 +15,12 @@ export const es: Dictionary = { text: "el concepto", information: "la línea de información", payload: "la estructura general", + tradeName: "el nombre comercial", + referenceParty: "la parte por cuya cuenta cobra", + instrument: "el tipo de transferencia", + currency: "la moneda", + category: "la categoría de comercio", + routing: "los datos de enrutamiento", other: "un elemento", }, rows: { @@ -27,6 +33,24 @@ export const es: Dictionary = { text: "Concepto", information: "Información", payerDecides: "lo introduce el pagador", + tradeName: "Nombre comercial", + onBehalfOf: "Por cuenta de", + onBehalfOfTrade: "Por cuenta de, nombre comercial", + transfer: "Transferencia", + instant: "Inmediata", + standard: "Ordinaria", + category: "Categoría de comercio", + context: "Contexto de pago", + contexts: { + m: "En tienda", + e: "Compra en línea", + i: "Factura", + p: "Entre particulares", + w: "Página web", + }, + framework: "Marco", + provider: "Proveedor", + issuer: "Emisor", }, common: { cancel: "Cancelar", @@ -55,6 +79,11 @@ export const es: Dictionary = { requestTooLong: "La solicitud no cabe en un código. Acorta el texto.", unencodable: "La solicitud no se pudo codificar.", inPayeeSettings: "(ajustes del beneficiario)", + amountRequired: "Un código EN 18184 lleva un importe fijo. Introduce uno.", + poiTextShape: "Hasta 35 caracteres visibles en una línea en un código EN 18184", + poiReferenceShape: + "Hasta 35 caracteres; una referencia que empieza por RF debe tener dígitos de control correctos", + poiProfile: "Revisa los ajustes de EN 18184.", }, rejections: { empty: "No hay nada que leer.", @@ -83,6 +112,13 @@ export const es: Dictionary = { linkNoRequest: "El enlace no lleva ninguna solicitud de pago.", linkDamaged: "El enlace está dañado y no se puede leer.", linkInvalid: "El enlace no lleva una solicitud de pago válida.", + poiNot: "La aplicación no puede leer esta dirección como un código de pago EN 18184. No se ha abierto nada.", + poiInvalid: (elements) => + `El código EN 18184 no es una solicitud de pago válida. Sin superar las comprobaciones: ${joinList(elements)}.`, + poiNoRequest: "El código EN 18184 no contiene una solicitud de pago válida.", + poiNeedsProvider: + "Este código EN 18184 identifica al beneficiario a través de un proveedor de pagos, y solo la aplicación de ese proveedor puede consultarlo. Euvena lee códigos que llevan los datos completos del beneficiario.", + poiNotEuro: "El código EN 18184 pide una moneda distinta del euro.", }, request: { title: "Solicitar dinero", @@ -110,6 +146,19 @@ export const es: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Hasta 140 caracteres de texto", referenceHint: "Una referencia estructurada del acreedor, hasta 35 caracteres", + format: "Formato del código", + formatEpc: "QR EPC", + formatPoi: "EN 18184", + formatEpcHint: "Lo leen la mayoría de las aplicaciones bancarias europeas.", + formatPoiHint: + "Para aplicaciones de un marco EN 18184. El código es una dirección web en el dominio del marco y lleva un importe fijo.", + transfer: "Transferencia", + instant: "Inmediata", + standard: "Ordinaria", + enterAmount: "Introduce un importe", + amountRequiredA11y: "Importe en euros, obligatorio para un código EN 18184", + poiTextHint: "Hasta 35 caracteres de texto", + poiReferenceHint: "Una referencia estructurada, hasta 35 caracteres", }, composed: { shareTitle: "Solicitud de pago", @@ -124,6 +173,8 @@ export const es: Dictionary = { whatTheCodeSays: "Lo que dice el código", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 versión ${version}, UTF-8, ${bytes} de ${maxBytes} bytes. QR versión ${qrVersion} de ${maxQrVersion}, corrección de errores ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) versión 1, ${characters} caracteres. QR versión ${qrVersion}, corrección de errores ${correction}.`, shareFailed: "La solicitud no se pudo compartir.", renderFailed: "El código no se pudo dibujar.", }, @@ -141,7 +192,7 @@ export const es: Dictionary = { "La cámara está desactivada para esta aplicación en los ajustes del sistema. Pegar abajo sigue funcionando.", pointCamera: "Apunta la cámara a un código QR de pago.", pasteLabel: "O pega una solicitud", - pastePlaceholder: "Un enlace euvena:// o payto://, o el texto de un código", + pastePlaceholder: "Un enlace euvena://, payto:// o https://, o el texto de un código", readPasted: "Leer lo pegado", reviewHint: "Valores leídos del propio código. Comprueba el nombre y el IBAN con quien pide el pago; el código no puede hacerlo por ti.", @@ -159,6 +210,10 @@ export const es: Dictionary = { rejectionHint: "Una solicitud que no supera una comprobación no se muestra en absoluto: una lectura parcial podría dirigir el dinero a la cuenta equivocada.", tryAgain: "Intentar de nuevo", + poiRead: + "Este código EN 18184 es una dirección web. La aplicación lo ha leído en este dispositivo y no ha abierto nada.", + instantAsked: + "El beneficiario pide una transferencia inmediata. Elígela en tu aplicación bancaria si la ofrece.", }, history: { title: "Historial", @@ -253,9 +308,31 @@ export const es: Dictionary = { requestsCount: (n) => `${n} solicitud${n === 1 ? "" : "es"}`, and: (a, b) => `${a} y ${b}`, shareDialog: "Guardar o enviar los datos de la aplicación", + poi: "Códigos EN 18184", + poiOff: "Desactivados", + poiHint: "Crea códigos para el punto de interacción con los datos que te asignó un marco MSCT.", about: "Acerca de", aboutText: - "Euvena es una aplicación de referencia para códigos de pago EPC069-12. No tiene cuentas ni servidor y nunca mueve dinero: una solicitud se entrega a tu aplicación bancaria.", + "Euvena es una aplicación de referencia para códigos de pago EPC069-12 y EN 18184. No tiene cuentas ni servidor y nunca mueve dinero: una solicitud se entrega a tu aplicación bancaria.", + }, + poi: { + title: "Códigos EN 18184", + subtitle: + "Solo se guarda en este dispositivo. Así las solicitudes también se pueden crear como códigos EN 18184, además de códigos QR EPC.", + domain: "Dominio del marco", + domainPlaceholder: "qr.example.org", + domainShape: "Introduce solo el nombre de host, sin https:// ni ruta", + providerId: "ID del proveedor", + providerPlaceholder: "3 letras o dígitos", + providerShape: "Exactamente 3 letras o dígitos", + issuer: "ID del emisor", + issuerPlaceholder: "3 letras o dígitos", + issuerShape: "Exactamente 3 letras o dígitos", + explainer: + "Un código EN 18184 es una dirección web en el dominio de un marco de interoperabilidad MSCT, dirigida a tu proveedor de pagos por su ID. Usa los valores que te dio tu marco o tu proveedor: los códigos creados con otros valores apuntan a una dirección que no es tuya.", + saveFailed: "Los ajustes no se pudieron guardar en este dispositivo.", + turnOff: "Desactivar los códigos EN 18184", + turningOff: "Desactivando", }, }; diff --git a/apps/wallet/src/i18n/fr.ts b/apps/wallet/src/i18n/fr.ts index 704c0e8..2b18de3 100644 --- a/apps/wallet/src/i18n/fr.ts +++ b/apps/wallet/src/i18n/fr.ts @@ -15,6 +15,12 @@ export const fr: Dictionary = { text: "le libellé", information: "la ligne d'information", payload: "la structure d'ensemble", + tradeName: "le nom commercial", + referenceParty: "la partie pour le compte de laquelle il encaisse", + instrument: "le type de virement", + currency: "la devise", + category: "la catégorie de commerçant", + routing: "les données de routage", other: "un élément", }, rows: { @@ -27,6 +33,24 @@ export const fr: Dictionary = { text: "Libellé", information: "Information", payerDecides: "saisi par le payeur", + tradeName: "Nom commercial", + onBehalfOf: "Pour le compte de", + onBehalfOfTrade: "Pour le compte de, nom commercial", + transfer: "Virement", + instant: "Instantané", + standard: "Standard", + category: "Catégorie de commerçant", + context: "Contexte de paiement", + contexts: { + m: "En magasin", + e: "Achat en ligne", + i: "Facture", + p: "Entre particuliers", + w: "Page web", + }, + framework: "Cadre", + provider: "Prestataire", + issuer: "Émetteur", }, common: { cancel: "Annuler", @@ -55,6 +79,11 @@ export const fr: Dictionary = { requestTooLong: "La demande ne tient pas dans un code. Raccourcissez le texte.", unencodable: "La demande n'a pas pu être encodée.", inPayeeSettings: "(réglages du bénéficiaire)", + amountRequired: "Un code EN 18184 porte un montant fixe. Saisissez-en un.", + poiTextShape: "Jusqu'à 35 caractères visibles sur une ligne dans un code EN 18184", + poiReferenceShape: + "Jusqu'à 35 caractères ; une référence qui commence par RF doit passer ses chiffres de contrôle", + poiProfile: "Vérifiez les réglages EN 18184.", }, rejections: { empty: "Il n'y a rien à lire.", @@ -83,6 +112,13 @@ export const fr: Dictionary = { linkNoRequest: "Le lien ne porte aucune demande de paiement.", linkDamaged: "Le lien est endommagé et ne peut pas être lu.", linkInvalid: "Le lien ne porte pas de demande de paiement valide.", + poiNot: "L'application ne peut pas lire cette adresse comme un code de paiement EN 18184. Rien n'a été ouvert.", + poiInvalid: (elements) => + `Le code EN 18184 n'est pas une demande de paiement valide. Éléments non conformes : ${joinList(elements)}.`, + poiNoRequest: "Le code EN 18184 ne contient pas de demande de paiement valide.", + poiNeedsProvider: + "Ce code EN 18184 désigne le bénéficiaire par l'intermédiaire d'un prestataire de paiement, que seule l'application de ce prestataire peut interroger. Euvena lit les codes qui portent les coordonnées complètes du bénéficiaire.", + poiNotEuro: "Le code EN 18184 demande une autre devise que l'euro.", }, request: { title: "Demander un paiement", @@ -110,6 +146,19 @@ export const fr: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Jusqu'à 140 caractères de texte", referenceHint: "Une référence créancier structurée, jusqu'à 35 caractères", + format: "Format du code", + formatEpc: "QR EPC", + formatPoi: "EN 18184", + formatEpcHint: "Lu par la plupart des applications bancaires européennes.", + formatPoiHint: + "Pour les applications d'un cadre EN 18184. Le code est une adresse web sur le domaine du cadre et porte un montant fixe.", + transfer: "Virement", + instant: "Instantané", + standard: "Standard", + enterAmount: "Saisissez un montant", + amountRequiredA11y: "Montant en euros, obligatoire pour un code EN 18184", + poiTextHint: "Jusqu'à 35 caractères de texte", + poiReferenceHint: "Une référence structurée, jusqu'à 35 caractères", }, composed: { shareTitle: "Demande de paiement", @@ -124,6 +173,8 @@ export const fr: Dictionary = { whatTheCodeSays: "Ce que dit le code", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 version ${version}, UTF-8, ${bytes} octets sur ${maxBytes}. QR version ${qrVersion} sur ${maxQrVersion}, correction d'erreur ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) version 1, ${characters} caractères. QR version ${qrVersion}, correction d'erreur ${correction}.`, shareFailed: "La demande n'a pas pu être partagée.", renderFailed: "Le code n'a pas pu être dessiné.", }, @@ -141,7 +192,7 @@ export const fr: Dictionary = { "La caméra est désactivée pour cette application dans les réglages du système. Le collage ci-dessous fonctionne toujours.", pointCamera: "Pointez la caméra vers un code QR de paiement.", pasteLabel: "Ou collez une demande", - pastePlaceholder: "Un lien euvena:// ou payto://, ou le texte d'un code", + pastePlaceholder: "Un lien euvena://, payto:// ou https://, ou le texte d'un code", readPasted: "Lire ce qui a été collé", reviewHint: "Valeurs lues dans le code lui-même. Vérifiez le nom et l'IBAN auprès de la personne qui demande à être payée ; le code ne peut pas le faire pour vous.", @@ -159,6 +210,10 @@ export const fr: Dictionary = { rejectionHint: "Une demande qui échoue à un contrôle n'est pas affichée du tout : une lecture partielle pourrait diriger l'argent vers le mauvais compte.", tryAgain: "Réessayer", + poiRead: + "Ce code EN 18184 est une adresse web. L'application l'a lu sur cet appareil et n'a rien ouvert.", + instantAsked: + "Le bénéficiaire demande un virement instantané. Choisissez-le dans votre application bancaire s'il est proposé.", }, history: { title: "Historique", @@ -253,9 +308,32 @@ export const fr: Dictionary = { requestsCount: (n) => `${n} demande${n === 1 ? "" : "s"}`, and: (a, b) => `${a} et ${b}`, shareDialog: "Enregistrer ou envoyer les données de l'application", + poi: "Codes EN 18184", + poiOff: "Désactivés", + poiHint: + "Créez des codes pour le point d'interaction avec les données qu'un cadre MSCT vous a attribuées.", about: "À propos", aboutText: - "Euvena est une application de référence pour les codes de paiement EPC069-12. Elle n'a ni comptes ni serveur et ne déplace jamais d'argent : une demande est remise à votre application bancaire.", + "Euvena est une application de référence pour les codes de paiement EPC069-12 et EN 18184. Elle n'a ni comptes ni serveur et ne déplace jamais d'argent : une demande est remise à votre application bancaire.", + }, + poi: { + title: "Codes EN 18184", + subtitle: + "Conservé sur cet appareil uniquement. Les demandes peuvent alors être créées en codes EN 18184 en plus des codes QR EPC.", + domain: "Domaine du cadre", + domainPlaceholder: "qr.example.org", + domainShape: "Saisissez le nom d'hôte seul, sans https:// ni chemin", + providerId: "ID du prestataire", + providerPlaceholder: "3 lettres ou chiffres", + providerShape: "Exactement 3 lettres ou chiffres", + issuer: "ID de l'émetteur", + issuerPlaceholder: "3 lettres ou chiffres", + issuerShape: "Exactement 3 lettres ou chiffres", + explainer: + "Un code EN 18184 est une adresse web sur le domaine d'un cadre d'interopérabilité MSCT, acheminée vers votre prestataire de paiement par son ID. Utilisez les valeurs que votre cadre ou votre prestataire vous a données : des codes créés avec d'autres valeurs pointent vers une adresse qui ne vous appartient pas.", + saveFailed: "Les réglages n'ont pas pu être enregistrés sur cet appareil.", + turnOff: "Désactiver les codes EN 18184", + turningOff: "Désactivation", }, }; diff --git a/apps/wallet/src/i18n/index.ts b/apps/wallet/src/i18n/index.ts index c053dab..3a91b2f 100644 --- a/apps/wallet/src/i18n/index.ts +++ b/apps/wallet/src/i18n/index.ts @@ -125,7 +125,7 @@ export function formatDateTime(iso: string, tag: string): string { /** Why an input was not read as a payment request. */ export interface Rejection { code: import("./dictionary").RejectionCode; - /** The elements that failed, for codeInvalid and paytoInvalid. */ + /** The elements that failed, for codeInvalid, paytoInvalid and poiInvalid. */ elements?: ElementKey[]; } @@ -141,6 +141,10 @@ export function describeRejection(rejection: Rejection, strings: Dictionary): st return elements.length === 0 ? strings.rejections.paytoNoRequest : strings.rejections.paytoInvalid(elements); + case "poiInvalid": + return elements.length === 0 + ? strings.rejections.poiNoRequest + : strings.rejections.poiInvalid(elements); default: return strings.rejections[rejection.code]; } diff --git a/apps/wallet/src/i18n/it.ts b/apps/wallet/src/i18n/it.ts index 3461003..82fc3fe 100644 --- a/apps/wallet/src/i18n/it.ts +++ b/apps/wallet/src/i18n/it.ts @@ -15,6 +15,12 @@ export const it: Dictionary = { text: "la causale", information: "la riga informativa", payload: "la struttura complessiva", + tradeName: "il nome commerciale", + referenceParty: "la parte per conto della quale incassa", + instrument: "il tipo di bonifico", + currency: "la valuta", + category: "la categoria esercente", + routing: "i dati di instradamento", other: "un elemento", }, rows: { @@ -27,6 +33,24 @@ export const it: Dictionary = { text: "Causale", information: "Informazioni", payerDecides: "inserito dal pagatore", + tradeName: "Nome commerciale", + onBehalfOf: "Per conto di", + onBehalfOfTrade: "Per conto di, nome commerciale", + transfer: "Bonifico", + instant: "Istantaneo", + standard: "Ordinario", + category: "Categoria esercente", + context: "Contesto di pagamento", + contexts: { + m: "In negozio", + e: "Acquisto online", + i: "Fattura", + p: "Tra persone", + w: "Pagina web", + }, + framework: "Framework", + provider: "Prestatore", + issuer: "Emittente", }, common: { cancel: "Annulla", @@ -55,6 +79,11 @@ export const it: Dictionary = { requestTooLong: "La richiesta non entra in un codice. Accorcia il testo.", unencodable: "La richiesta non è stata codificata.", inPayeeSettings: "(impostazioni del beneficiario)", + amountRequired: "Un codice EN 18184 contiene un importo fisso. Inseriscine uno.", + poiTextShape: "Fino a 35 caratteri visibili su una riga in un codice EN 18184", + poiReferenceShape: + "Fino a 35 caratteri; un riferimento che inizia con RF deve avere cifre di controllo corrette", + poiProfile: "Controlla le impostazioni EN 18184.", }, rejections: { empty: "Non c'è nulla da leggere.", @@ -83,6 +112,13 @@ export const it: Dictionary = { linkNoRequest: "Il link non contiene alcuna richiesta di pagamento.", linkDamaged: "Il link è danneggiato e non può essere letto.", linkInvalid: "Il link non contiene una richiesta di pagamento valida.", + poiNot: "L'app non riesce a leggere questo indirizzo come codice di pagamento EN 18184. Non è stato aperto nulla.", + poiInvalid: (elements) => + `Il codice EN 18184 non è una richiesta di pagamento valida. Controlli non superati: ${joinList(elements)}.`, + poiNoRequest: "Il codice EN 18184 non contiene una richiesta di pagamento valida.", + poiNeedsProvider: + "Questo codice EN 18184 indica il beneficiario tramite un prestatore di servizi di pagamento, che solo l'app di quel prestatore può consultare. Euvena legge i codici che contengono per intero i dati del beneficiario.", + poiNotEuro: "Il codice EN 18184 chiede una valuta diversa dall'euro.", }, request: { title: "Richiedi denaro", @@ -110,6 +146,19 @@ export const it: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Fino a 140 caratteri di testo", referenceHint: "Un riferimento strutturato del creditore, fino a 35 caratteri", + format: "Formato del codice", + formatEpc: "QR EPC", + formatPoi: "EN 18184", + formatEpcHint: "Letto dalla maggior parte delle app bancarie europee.", + formatPoiHint: + "Per le app di un framework EN 18184. Il codice è un indirizzo web sul dominio del framework e contiene un importo fisso.", + transfer: "Bonifico", + instant: "Istantaneo", + standard: "Ordinario", + enterAmount: "Inserisci un importo", + amountRequiredA11y: "Importo in euro, obbligatorio per un codice EN 18184", + poiTextHint: "Fino a 35 caratteri di testo", + poiReferenceHint: "Un riferimento strutturato, fino a 35 caratteri", }, composed: { shareTitle: "Richiesta di pagamento", @@ -124,6 +173,8 @@ export const it: Dictionary = { whatTheCodeSays: "Cosa dice il codice", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 versione ${version}, UTF-8, ${bytes} byte su ${maxBytes}. QR versione ${qrVersion} su ${maxQrVersion}, correzione errori ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) versione 1, ${characters} caratteri. QR versione ${qrVersion}, correzione d'errore ${correction}.`, shareFailed: "La richiesta non è stata condivisa.", renderFailed: "Il codice non è stato disegnato.", }, @@ -141,7 +192,7 @@ export const it: Dictionary = { "La fotocamera è disattivata per questa app nelle impostazioni di sistema. Incollare qui sotto funziona comunque.", pointCamera: "Inquadra un codice QR di pagamento.", pasteLabel: "Oppure incolla una richiesta", - pastePlaceholder: "Un link euvena:// o payto://, oppure il testo di un codice", + pastePlaceholder: "Un link euvena://, payto:// o https://, oppure il testo di un codice", readPasted: "Leggi quanto incollato", reviewHint: "Valori letti dal codice stesso. Verifica nome e IBAN con chi chiede il pagamento; il codice non può farlo per te.", @@ -159,6 +210,10 @@ export const it: Dictionary = { rejectionHint: "Una richiesta che non supera un controllo non viene mostrata affatto: una lettura parziale potrebbe indirizzare il denaro al conto sbagliato.", tryAgain: "Riprova", + poiRead: + "Questo codice EN 18184 è un indirizzo web. L'app lo ha letto su questo dispositivo e non ha aperto nulla.", + instantAsked: + "Il beneficiario chiede un bonifico istantaneo. Sceglilo nella tua app bancaria, se è disponibile.", }, history: { title: "Cronologia", @@ -253,9 +308,31 @@ export const it: Dictionary = { requestsCount: (n) => `${n} ${n === 1 ? "richiesta" : "richieste"}`, and: (a, b) => `${a} e ${b}`, shareDialog: "Salva o invia i dati dell'app", + poi: "Codici EN 18184", + poiOff: "Disattivati", + poiHint: "Crea codici per il punto di interazione con i dati che un framework MSCT ti ha assegnato.", about: "Informazioni", aboutText: - "Euvena è un'app di riferimento per i codici di pagamento EPC069-12. Non ha account né server e non sposta mai denaro: una richiesta viene passata alla tua app bancaria.", + "Euvena è un'app di riferimento per i codici di pagamento EPC069-12 ed EN 18184. Non ha account né server e non sposta mai denaro: una richiesta viene passata alla tua app bancaria.", + }, + poi: { + title: "Codici EN 18184", + subtitle: + "Conservato solo su questo dispositivo. Le richieste si possono poi creare anche come codici EN 18184, oltre che come codici QR EPC.", + domain: "Dominio del framework", + domainPlaceholder: "qr.example.org", + domainShape: "Inserisci solo il nome host, senza https:// né percorso", + providerId: "ID del prestatore", + providerPlaceholder: "3 lettere o cifre", + providerShape: "Esattamente 3 lettere o cifre", + issuer: "ID dell'emittente", + issuerPlaceholder: "3 lettere o cifre", + issuerShape: "Esattamente 3 lettere o cifre", + explainer: + "Un codice EN 18184 è un indirizzo web sul dominio di un framework di interoperabilità MSCT, instradato al tuo prestatore di servizi di pagamento tramite il suo ID. Usa i valori che ti ha dato il tuo framework o prestatore: i codici creati con altri valori puntano a un indirizzo che non è tuo.", + saveFailed: "Le impostazioni non sono state salvate su questo dispositivo.", + turnOff: "Disattiva i codici EN 18184", + turningOff: "Disattivazione", }, }; diff --git a/apps/wallet/src/i18n/nl.ts b/apps/wallet/src/i18n/nl.ts index 1f35604..2efc490 100644 --- a/apps/wallet/src/i18n/nl.ts +++ b/apps/wallet/src/i18n/nl.ts @@ -15,6 +15,12 @@ export const nl: Dictionary = { text: "de omschrijving", information: "de informatieregel", payload: "de algehele opbouw", + tradeName: "de handelsnaam", + referenceParty: "de partij namens wie wordt geïnd", + instrument: "het soort overboeking", + currency: "de valuta", + category: "de branchecode", + routing: "de routeringsgegevens", other: "een element", }, rows: { @@ -27,6 +33,24 @@ export const nl: Dictionary = { text: "Omschrijving", information: "Informatie", payerDecides: "ingevuld door de betaler", + tradeName: "Handelsnaam", + onBehalfOf: "Namens", + onBehalfOfTrade: "Namens, handelsnaam", + transfer: "Overboeking", + instant: "Direct", + standard: "Standaard", + category: "Branchecode", + context: "Betaalcontext", + contexts: { + m: "In de winkel", + e: "Online aankoop", + i: "Factuur", + p: "Tussen personen", + w: "Webpagina", + }, + framework: "Raamwerk", + provider: "Aanbieder", + issuer: "Uitgever", }, common: { cancel: "Annuleren", @@ -55,6 +79,11 @@ export const nl: Dictionary = { requestTooLong: "Het verzoek past niet in een code. Kort de tekst in.", unencodable: "Het verzoek kon niet worden gecodeerd.", inPayeeSettings: "(instellingen begunstigde)", + amountRequired: "Een EN 18184-code bevat een vast bedrag. Vul er een in.", + poiTextShape: "Tot 35 zichtbare tekens op één regel in een EN 18184-code", + poiReferenceShape: + "Tot 35 tekens; een kenmerk dat met RF begint, moet zijn controlecijfers doorstaan", + poiProfile: "Controleer de EN 18184-instellingen.", }, rejections: { empty: "Er is niets te lezen.", @@ -83,6 +112,13 @@ export const nl: Dictionary = { linkNoRequest: "De link bevat geen betaalverzoek.", linkDamaged: "De link is beschadigd en kan niet worden gelezen.", linkInvalid: "De link bevat geen geldig betaalverzoek.", + poiNot: "De app kan dit adres niet lezen als EN 18184-betaalcode. Er is niets geopend.", + poiInvalid: (elements) => + `De EN 18184-code is geen geldig betaalverzoek. Niet door de controles: ${joinList(elements)}.`, + poiNoRequest: "De EN 18184-code bevat geen geldig betaalverzoek.", + poiNeedsProvider: + "Deze EN 18184-code wijst de begunstigde aan via een betaaldienstverlener, en alleen de app van die aanbieder kan dat opzoeken. Euvena leest codes die de gegevens van de begunstigde volledig bevatten.", + poiNotEuro: "De EN 18184-code vraagt om een andere valuta dan de euro.", }, request: { title: "Geld vragen", @@ -110,6 +146,19 @@ export const nl: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Tot 140 tekens tekst", referenceHint: "Een gestructureerd kenmerk van de schuldeiser, tot 35 tekens", + format: "Codeformaat", + formatEpc: "EPC-QR", + formatPoi: "EN 18184", + formatEpcHint: "Gelezen door de meeste Europese bankapps.", + formatPoiHint: + "Voor apps in een EN 18184-raamwerk. De code is een webadres op het domein van het raamwerk en bevat een vast bedrag.", + transfer: "Overboeking", + instant: "Direct", + standard: "Standaard", + enterAmount: "Vul een bedrag in", + amountRequiredA11y: "Bedrag in euro, verplicht voor een EN 18184-code", + poiTextHint: "Tot 35 tekens tekst", + poiReferenceHint: "Een gestructureerd kenmerk, tot 35 tekens", }, composed: { shareTitle: "Betaalverzoek", @@ -124,6 +173,8 @@ export const nl: Dictionary = { whatTheCodeSays: "Wat de code zegt", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 versie ${version}, UTF-8, ${bytes} van ${maxBytes} bytes. QR-versie ${qrVersion} van ${maxQrVersion}, foutcorrectie ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) versie 1, ${characters} tekens. QR-versie ${qrVersion}, foutcorrectie ${correction}.`, shareFailed: "Het verzoek kon niet worden gedeeld.", renderFailed: "De code kon niet worden getekend.", }, @@ -141,7 +192,7 @@ export const nl: Dictionary = { "De camera staat voor deze app uit in de systeeminstellingen. Plakken hieronder werkt nog wel.", pointCamera: "Richt de camera op een betaal-QR-code.", pasteLabel: "Of plak een verzoek", - pastePlaceholder: "Een euvena://- of payto://-link, of de tekst van een code", + pastePlaceholder: "Een euvena://-, payto://- of https://-link, of de tekst van een code", readPasted: "Geplakte tekst lezen", reviewHint: "Waarden uit de code zelf. Controleer naam en IBAN bij degene die om betaling vraagt; de code kan dat niet voor je doen.", @@ -159,6 +210,10 @@ export const nl: Dictionary = { rejectionHint: "Een verzoek dat een controle niet doorstaat wordt helemaal niet getoond: een gedeeltelijke lezing zou geld naar de verkeerde rekening kunnen sturen.", tryAgain: "Opnieuw proberen", + poiRead: + "Deze EN 18184-code is een webadres. De app heeft hem op dit apparaat gelezen en niets geopend.", + instantAsked: + "De begunstigde vraagt om een directe overboeking. Kies die in je bankapp als die wordt aangeboden.", }, history: { title: "Geschiedenis", @@ -253,9 +308,31 @@ export const nl: Dictionary = { requestsCount: (n) => `${n} verzoek${n === 1 ? "" : "en"}`, and: (a, b) => `${a} en ${b}`, shareDialog: "Gegevens van de app opslaan of versturen", + poi: "EN 18184-codes", + poiOff: "Uit", + poiHint: "Maak codes voor het interactiepunt met de gegevens die een MSCT-raamwerk je heeft toegewezen.", about: "Over", aboutText: - "Euvena is een referentie-app voor EPC069-12-betaalcodes. De app heeft geen accounts en geen server en verplaatst nooit geld: een verzoek wordt aan je bankapp doorgegeven.", + "Euvena is een referentie-app voor EPC069-12- en EN 18184-betaalcodes. De app heeft geen accounts en geen server en verplaatst nooit geld: een verzoek wordt aan je bankapp doorgegeven.", + }, + poi: { + title: "EN 18184-codes", + subtitle: + "Alleen op dit apparaat bewaard. Verzoeken kunnen dan ook als EN 18184-code worden gemaakt, naast EPC-QR-codes.", + domain: "Domein van het raamwerk", + domainPlaceholder: "qr.example.org", + domainShape: "Vul alleen de hostnaam in, zonder https:// of pad", + providerId: "Aanbieder-ID", + providerPlaceholder: "3 letters of cijfers", + providerShape: "Precies 3 letters of cijfers", + issuer: "Uitgever-ID", + issuerPlaceholder: "3 letters of cijfers", + issuerShape: "Precies 3 letters of cijfers", + explainer: + "Een EN 18184-code is een webadres op het domein van een MSCT-interoperabiliteitsraamwerk, dat via de ID naar je betaaldienstverlener wordt geleid. Gebruik de waarden die je raamwerk of aanbieder je heeft gegeven: codes met andere waarden verwijzen naar een adres dat niet van jou is.", + saveFailed: "De instellingen konden niet op dit apparaat worden opgeslagen.", + turnOff: "EN 18184-codes uitzetten", + turningOff: "Wordt uitgezet", }, }; diff --git a/apps/wallet/src/i18n/pl.ts b/apps/wallet/src/i18n/pl.ts index 3c61fa9..318ce02 100644 --- a/apps/wallet/src/i18n/pl.ts +++ b/apps/wallet/src/i18n/pl.ts @@ -15,6 +15,12 @@ export const pl: Dictionary = { text: "tytuł przelewu", information: "wiersz informacyjny", payload: "ogólna struktura", + tradeName: "nazwa handlowa", + referenceParty: "strona, w której imieniu pobiera", + instrument: "rodzaj przelewu", + currency: "waluta", + category: "kategoria sprzedawcy", + routing: "dane routingu", other: "element", }, rows: { @@ -27,6 +33,24 @@ export const pl: Dictionary = { text: "Tytuł", information: "Informacja", payerDecides: "wpisuje płacący", + tradeName: "Nazwa handlowa", + onBehalfOf: "W imieniu", + onBehalfOfTrade: "W imieniu, nazwa handlowa", + transfer: "Przelew", + instant: "Natychmiastowy", + standard: "Standardowy", + category: "Kategoria sprzedawcy", + context: "Kontekst płatności", + contexts: { + m: "W sklepie", + e: "Zakup online", + i: "Faktura", + p: "Między osobami", + w: "Strona internetowa", + }, + framework: "Ramy", + provider: "Dostawca", + issuer: "Wydawca", }, common: { cancel: "Anuluj", @@ -55,6 +79,11 @@ export const pl: Dictionary = { requestTooLong: "Żądanie nie mieści się w kodzie. Skróć tekst.", unencodable: "Nie udało się zakodować żądania.", inPayeeSettings: "(ustawienia odbiorcy)", + amountRequired: "Kod EN 18184 zawiera stałą kwotę. Wpisz ją.", + poiTextShape: "Do 35 widocznych znaków w jednym wierszu w kodzie EN 18184", + poiReferenceShape: + "Do 35 znaków; referencja zaczynająca się od RF musi mieć poprawne cyfry kontrolne", + poiProfile: "Sprawdź ustawienia EN 18184.", }, rejections: { empty: "Nie ma nic do odczytania.", @@ -83,6 +112,13 @@ export const pl: Dictionary = { linkNoRequest: "Link nie zawiera żądania płatności.", linkDamaged: "Link jest uszkodzony i nie można go odczytać.", linkInvalid: "Link nie zawiera prawidłowego żądania płatności.", + poiNot: "Aplikacja nie może odczytać tego adresu jako kodu płatności EN 18184. Nic nie zostało otwarte.", + poiInvalid: (elements) => + `Kod EN 18184 nie jest prawidłowym żądaniem płatności. Bez pozytywnej kontroli: ${joinList(elements)}.`, + poiNoRequest: "Kod EN 18184 nie zawiera prawidłowego żądania płatności.", + poiNeedsProvider: + "Ten kod EN 18184 wskazuje odbiorcę przez dostawcę usług płatniczych, a odnaleźć go może tylko aplikacja tego dostawcy. Euvena odczytuje kody, które zawierają pełne dane odbiorcy.", + poiNotEuro: "Kod EN 18184 wymaga waluty innej niż euro.", }, request: { title: "Poproś o pieniądze", @@ -110,6 +146,19 @@ export const pl: Dictionary = { referencePlaceholder: "RF18539007547034", textHint: "Do 140 znaków tekstu", referenceHint: "Ustrukturyzowana referencja wierzyciela, do 35 znaków", + format: "Format kodu", + formatEpc: "QR EPC", + formatPoi: "EN 18184", + formatEpcHint: "Odczytuje go większość europejskich aplikacji bankowych.", + formatPoiHint: + "Dla aplikacji w ramach EN 18184. Kod jest adresem internetowym w domenie tych ram i zawiera stałą kwotę.", + transfer: "Przelew", + instant: "Natychmiastowy", + standard: "Standardowy", + enterAmount: "Wpisz kwotę", + amountRequiredA11y: "Kwota w euro, wymagana dla kodu EN 18184", + poiTextHint: "Do 35 znaków tekstu", + poiReferenceHint: "Ustrukturyzowana referencja, do 35 znaków", }, composed: { shareTitle: "Żądanie płatności", @@ -124,6 +173,8 @@ export const pl: Dictionary = { whatTheCodeSays: "Co mówi kod", figures: ({ version, bytes, maxBytes, qrVersion, maxQrVersion, correction }) => `EPC069-12 wersja ${version}, UTF-8, ${bytes} z ${maxBytes} bajtów. Wersja QR ${qrVersion} z ${maxQrVersion}, korekcja błędów ${correction}.`, + poiFigures: ({ characters, qrVersion, correction }) => + `EN 18184 (EPC024-22) wersja 1, ${characters} ${pluralPl(characters, "znak", "znaki", "znaków")}. Wersja QR ${qrVersion}, korekcja błędów ${correction}.`, shareFailed: "Nie udało się udostępnić żądania.", renderFailed: "Nie udało się narysować kodu.", }, @@ -141,7 +192,7 @@ export const pl: Dictionary = { "Aparat jest wyłączony dla tej aplikacji w ustawieniach systemu. Wklejanie poniżej nadal działa.", pointCamera: "Skieruj aparat na kod QR płatności.", pasteLabel: "Lub wklej żądanie", - pastePlaceholder: "Link euvena:// lub payto://, albo tekst kodu", + pastePlaceholder: "Link euvena://, payto:// lub https://, albo tekst kodu", readPasted: "Odczytaj wklejone", reviewHint: "Wartości odczytane z samego kodu. Sprawdź nazwę i IBAN u osoby, która prosi o płatność; kod nie zrobi tego za Ciebie.", @@ -159,6 +210,10 @@ export const pl: Dictionary = { rejectionHint: "Żądanie, które nie przejdzie kontroli, nie jest w ogóle pokazywane: częściowy odczyt mógłby skierować pieniądze na niewłaściwy rachunek.", tryAgain: "Spróbuj ponownie", + poiRead: + "Ten kod EN 18184 jest adresem internetowym. Aplikacja odczytała go na tym urządzeniu i niczego nie otworzyła.", + instantAsked: + "Odbiorca prosi o przelew natychmiastowy. Wybierz go w swojej aplikacji bankowej, jeśli jest dostępny.", }, history: { title: "Historia", @@ -253,9 +308,31 @@ export const pl: Dictionary = { requestsCount: (n) => `${n} ${pluralPl(n, "żądanie", "żądania", "żądań")}`, and: (a, b) => `${a} i ${b}`, shareDialog: "Zapisz lub wyślij dane aplikacji", + poi: "Kody EN 18184", + poiOff: "Wyłączone", + poiHint: "Twórz kody dla punktu interakcji z danymi, które przydzieliły Ci ramy MSCT.", about: "O aplikacji", aboutText: - "Euvena to referencyjna aplikacja do kodów płatności EPC069-12. Nie ma kont ani serwera i nigdy nie przenosi pieniędzy: żądanie jest przekazywane do Twojej aplikacji bankowej.", + "Euvena to referencyjna aplikacja do kodów płatności EPC069-12 i EN 18184. Nie ma kont ani serwera i nigdy nie przenosi pieniędzy: żądanie jest przekazywane do Twojej aplikacji bankowej.", + }, + poi: { + title: "Kody EN 18184", + subtitle: + "Przechowywane tylko na tym urządzeniu. Żądania można wtedy tworzyć także jako kody EN 18184, obok kodów QR EPC.", + domain: "Domena ram", + domainPlaceholder: "qr.example.org", + domainShape: "Wpisz samą nazwę hosta, bez https:// i ścieżki", + providerId: "ID dostawcy", + providerPlaceholder: "3 litery lub cyfry", + providerShape: "Dokładnie 3 litery lub cyfry", + issuer: "ID wydawcy", + issuerPlaceholder: "3 litery lub cyfry", + issuerShape: "Dokładnie 3 litery lub cyfry", + explainer: + "Kod EN 18184 to adres internetowy w domenie ram interoperacyjności MSCT, kierowany do Twojego dostawcy usług płatniczych według jego ID. Użyj wartości, które podały Ci ramy lub dostawca: kody z innymi wartościami wskazują adres, który nie należy do Ciebie.", + saveFailed: "Nie udało się zapisać ustawień na tym urządzeniu.", + turnOff: "Wyłącz kody EN 18184", + turningOff: "Wyłączanie", }, }; diff --git a/apps/wallet/src/qr/symbol.ts b/apps/wallet/src/qr/symbol.ts index 98a66ad..cbaed06 100644 --- a/apps/wallet/src/qr/symbol.ts +++ b/apps/wallet/src/qr/symbol.ts @@ -1,7 +1,7 @@ /** - * Builds the QR symbol for an EPC069-12 payload. + * Builds the QR symbol for a payment code. * - * The guidelines fix two rendering parameters (EPC069-12 v3.1 section 3): + * For EPC069-12, the guidelines fix two rendering parameters (v3.1 section 3): * error correction level M and a symbol no larger than version 13. A * conformant payload is at most 331 bytes, which is exactly the byte-mode * capacity of version 13 at level M, so the cap is reachable but never @@ -21,28 +21,33 @@ export const EPC069_ERROR_CORRECTION = "M"; /** Largest symbol version the guidelines allow. */ export const EPC069_MAX_VERSION = 13; +/** + * Largest symbol version of ISO/IEC 18004. EPC024-22 names the standard and + * fixes no parameters of its own, so an EN 18184 code is bounded only by it + * and is drawn at the same level M. + */ +export const QR_MAX_VERSION = 40; + export interface QrSymbol { /** Module count per side, excluding the quiet zone. */ size: number; - /** QR symbol version, 1..13 for conformant payloads. */ + /** QR symbol version, 1..13 for conformant EPC069-12 payloads. */ version: number; /** Row-major dark-module flags, `size` rows of `size` entries. */ modules: boolean[][]; } /** - * @throws Error when the payload does not fit a version 13 symbol at level M. + * @throws Error when the payload does not fit a symbol of `maxVersion` at level M. */ -export function toQrSymbol(payload: string): QrSymbol { +export function toQrSymbol(payload: string, maxVersion: number = EPC069_MAX_VERSION): QrSymbol { const bytes = new TextEncoder().encode(payload); const symbol = create([{ mode: "byte", data: bytes }], { errorCorrectionLevel: EPC069_ERROR_CORRECTION, }); - if (symbol.version > EPC069_MAX_VERSION) { - throw new Error( - `payload needs QR version ${symbol.version}, the guidelines allow up to ${EPC069_MAX_VERSION}`, - ); + if (symbol.version > maxVersion) { + throw new Error(`payload needs QR version ${symbol.version}, up to ${maxVersion} is allowed`); } const size = symbol.modules.size; diff --git a/apps/wallet/src/settings/history.ts b/apps/wallet/src/settings/history.ts index 5ada320..b375c5b 100644 --- a/apps/wallet/src/settings/history.ts +++ b/apps/wallet/src/settings/history.ts @@ -15,7 +15,7 @@ export interface HistoryEntry { /** Unique within the list. Derived from the build time, see rememberRequest. */ id: string; - /** The EPC069-12 payload, exactly as the code carried it. */ + /** What the code carried, exactly: an EPC069-12 payload or an EN 18184 URL. */ payload: string; /** When the request was built, as an ISO 8601 instant. */ builtAt: string; @@ -27,10 +27,17 @@ export interface HistoryEntry { export const HISTORY_LIMIT = 200; /** - * Longest payload an entry may hold. A conformant payload is at most 331 - * bytes, so anything far past that is not something this app wrote. + * Longest payload an entry may hold. A conformant EPC069-12 payload is at most + * 331 bytes and an EN 18184 URL the wallet builds, with every text field full + * of escaped characters, stays under 2048, so anything past that is not + * something this app wrote. */ -export const MAX_PAYLOAD_LENGTH = 1024; +export const MAX_PAYLOAD_LENGTH = 2048; + +/** How the two kinds of payload begin; an EN 18184 URL is compared without case. */ +function isPayloadShaped(payload: string): boolean { + return payload.startsWith("BCD") || payload.slice(0, 8).toLowerCase() === "https://"; +} /** * Reads a stored history, dropping anything that is not a well-formed entry. @@ -80,9 +87,9 @@ function parseEntry(item: unknown): HistoryEntry | null { typeof record.id !== "string" || record.id === "" || typeof record.payload !== "string" || - // Only an EPC069-12 payload is shown by decoding it as one. Any other - // string would be read as a link and shown as something else. - !record.payload.startsWith("BCD") || + // Only a code's payload is shown by decoding it as one. Any other string + // would be read as a link and shown as something else. + !isPayloadShaped(record.payload) || record.payload.length > MAX_PAYLOAD_LENGTH || typeof record.builtAt !== "string" || Number.isNaN(Date.parse(record.builtAt)) diff --git a/apps/wallet/src/settings/preferences.ts b/apps/wallet/src/settings/preferences.ts index 722822e..c741d2b 100644 --- a/apps/wallet/src/settings/preferences.ts +++ b/apps/wallet/src/settings/preferences.ts @@ -1,9 +1,12 @@ /** - * What the user chose about the wallet itself: how it looks and which - * language it speaks. Both are on-device settings beside the payees. A - * language of null means the device language decides. + * What the user chose about the wallet itself: how it looks, which language + * it speaks and, when there is one, the EN 18184 profile its codes are built + * with. All are on-device settings beside the payees. A language of null + * means the device language decides; a profile of null means the wallet + * builds EPC069-12 codes only. */ +import { normalizePoiProfile, poiProfileIssues, type PoiProfile } from "../epc/poi"; import { isLanguage, type Language } from "../i18n"; export type Appearance = "system" | "light" | "dark"; @@ -13,9 +16,10 @@ export const APPEARANCES: readonly Appearance[] = ["system", "light", "dark"]; export interface Preferences { appearance: Appearance; language: Language | null; + poi: PoiProfile | null; } -export const DEFAULT_PREFERENCES: Preferences = { appearance: "system", language: null }; +export const DEFAULT_PREFERENCES: Preferences = { appearance: "system", language: null, poi: null }; /** Reads stored preferences, falling back field by field on anything unexpected. */ export function parsePreferences(stored: string | null): Preferences { @@ -33,6 +37,7 @@ export function parsePreferences(stored: string | null): Preferences { return { appearance: isAppearance(record.appearance) ? record.appearance : "system", language: isLanguage(record.language) ? record.language : null, + poi: readPoiProfile(record.poi), }; } @@ -40,9 +45,26 @@ export function serializePreferences(preferences: Preferences): string { return JSON.stringify({ appearance: preferences.appearance, language: preferences.language, + poi: preferences.poi, }); } +/** + * A stored profile, or null. One that the encoder would refuse is dropped + * whole: half a profile builds no code, and the settings offer to enter it + * again. + */ +function readPoiProfile(value: unknown): PoiProfile | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + const { domain, providerId, issuer } = record; + if (typeof domain !== "string" || typeof providerId !== "string" || typeof issuer !== "string") { + return null; + } + const profile = normalizePoiProfile({ domain, providerId, issuer }); + return poiProfileIssues(profile).length === 0 ? profile : null; +} + function isAppearance(value: unknown): value is Appearance { return typeof value === "string" && (APPEARANCES as readonly string[]).includes(value); } diff --git a/apps/wallet/src/ui/ComposedRequest.tsx b/apps/wallet/src/ui/ComposedRequest.tsx index 3807659..b16fefe 100644 --- a/apps/wallet/src/ui/ComposedRequest.tsx +++ b/apps/wallet/src/ui/ComposedRequest.tsx @@ -1,13 +1,14 @@ import { useCallback, useMemo, useState } from "react"; import { Share, StyleSheet, View, useWindowDimensions } from "react-native"; -import { EPC069_MAX_BYTES, byteLength, type EpcQrData } from "@euvena/qr"; +import { EPC069_MAX_BYTES, byteLength } from "@euvena/qr"; import { buildShareMessage } from "../epc/link"; -import { summarizeRequest } from "../epc/request"; +import { summarizeRequest, type PaymentCode } from "../epc/request"; import { useLocale } from "../i18n/context"; import { EPC069_ERROR_CORRECTION, EPC069_MAX_VERSION, + QR_MAX_VERSION, toQrSymbol, type QrSymbol, } from "../qr/symbol"; @@ -16,17 +17,17 @@ import { QrCode } from "./QrCode"; type SymbolResult = { symbol: QrSymbol } | { error: true }; -function buildSymbol(payload: string): SymbolResult { +function buildSymbol(code: PaymentCode): SymbolResult { try { - return { symbol: toQrSymbol(payload) }; + const maxVersion = code.format === "epc069" ? EPC069_MAX_VERSION : QR_MAX_VERSION; + return { symbol: toQrSymbol(code.payload, maxVersion) }; } catch { return { error: true }; } } interface ComposedRequestProps { - payload: string; - data: EpcQrData; + code: PaymentCode; /** * Keeps the request in the history. Called when it is shared, or from the * Keep action, which is only offered when this is given. Rejects when the @@ -48,10 +49,10 @@ interface ComposedRequestProps { * composing this way, and the history shows a kept one the same way, so what * is re-shared later is exactly what was shown at the time. */ -export function ComposedRequest({ payload, data, onKeep, compact = false }: ComposedRequestProps) { +export function ComposedRequest({ code, onKeep, compact = false }: ComposedRequestProps) { const { width } = useWindowDimensions(); const { strings } = useLocale(); - const rendered = useMemo(() => buildSymbol(payload), [payload]); + const rendered = useMemo(() => buildSymbol(code), [code]); const codeSize = Math.min(width - 104, 280); if ("error" in rendered) { @@ -68,8 +69,8 @@ export function ComposedRequest({ payload, data, onKeep, compact = false }: Comp {/* Keyed on the payload so an error from one request is not left standing over the next one. */} - - + + ); @@ -99,14 +100,13 @@ function QrCodeCard({ symbol, size }: { symbol: QrSymbol; size: number }) { * destination is the user's choice. */ function ShareRequest({ - payload, - data, + code, onKeep, }: { - payload: string; - data: EpcQrData; + code: PaymentCode; onKeep: ((payload: string) => Promise) | undefined; }) { + const { payload } = code; const { strings, tag } = useLocale(); const [sharing, setSharing] = useState(false); const [keeping, setKeeping] = useState(false); @@ -138,7 +138,7 @@ function ShareRequest({ // subject option, so it is passed as both. const title = strings.composed.shareTitle; const result = await Share.share( - { title, message: buildShareMessage(data, payload, strings, tag) }, + { title, message: buildShareMessage(code, strings, tag) }, { subject: title }, ); // iOS reports a sheet closed without a destination; Android settles as @@ -154,7 +154,7 @@ function ShareRequest({ } finally { setSharing(false); } - }, [payload, data, keep, strings, tag]); + }, [code, keep, strings, tag]); return ( @@ -198,13 +198,11 @@ function ShareRequest({ * recommend printing beside the code. */ function DecodedSummary({ - payload, - data, + code, symbol, compact, }: { - payload: string; - data: EpcQrData; + code: PaymentCode; symbol: QrSymbol; compact: boolean; }) { @@ -222,16 +220,22 @@ function DecodedSummary({ return ( {strings.composed.whatTheCodeSays} - + - {strings.composed.figures({ - version: data.version, - bytes: byteLength(payload, data.charset), - maxBytes: EPC069_MAX_BYTES, - qrVersion: symbol.version, - maxQrVersion: EPC069_MAX_VERSION, - correction: EPC069_ERROR_CORRECTION, - })} + {code.format === "epc069" + ? strings.composed.figures({ + version: code.data.version, + bytes: byteLength(code.payload, code.data.charset), + maxBytes: EPC069_MAX_BYTES, + qrVersion: symbol.version, + maxQrVersion: EPC069_MAX_VERSION, + correction: EPC069_ERROR_CORRECTION, + }) + : strings.composed.poiFigures({ + characters: code.payload.length, + qrVersion: symbol.version, + correction: EPC069_ERROR_CORRECTION, + })} ); diff --git a/apps/wallet/src/ui/HistoryScreen.tsx b/apps/wallet/src/ui/HistoryScreen.tsx index e9fb503..c6dc8a5 100644 --- a/apps/wallet/src/ui/HistoryScreen.tsx +++ b/apps/wallet/src/ui/HistoryScreen.tsx @@ -87,7 +87,7 @@ export function HistoryScreen({ {markFailedId === open.id ? {strings.history.markFailed} : null} {openResult.ok ? ( - + ) : ( {strings.history.unreadable} @@ -151,7 +151,7 @@ function EntryCard({ {result?.ok === true ? ( - + ) : ( {strings.history.unreadable} )} diff --git a/apps/wallet/src/ui/PoiScreen.tsx b/apps/wallet/src/ui/PoiScreen.tsx new file mode 100644 index 0000000..5893e86 --- /dev/null +++ b/apps/wallet/src/ui/PoiScreen.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { BackHandler, StyleSheet, View } from "react-native"; + +import { + EMPTY_POI_PROFILE, + normalizePoiProfile, + poiProfileIssues, + type PoiProfile, + type PoiProfileField, +} from "../epc/poi"; +import { type Dictionary } from "../i18n"; +import { useStrings } from "../i18n/context"; +import { Button, Card, Field, Header, Hint, Input, Problem, Screen } from "./kit"; + +interface PoiScreenProps { + /** The profile in force, or null while EN 18184 codes are off. */ + profile: PoiProfile | null; + /** Saves a profile, or null to turn the codes off. Rejects on a failed write. */ + onSave: (profile: PoiProfile | null) => Promise; + onCancel: () => void; +} + +/** + * Sets up the EN 18184 profile: the framework domain, the provider ID and the + * issuer ID a code carries. None of the three can be derived or looked up, so + * the form explains where they come from and the encoder decides what saves. + */ +export function PoiScreen({ profile, onSave, onCancel }: PoiScreenProps) { + const strings = useStrings(); + const [draft, setDraft] = useState(profile ?? EMPTY_POI_PROFILE); + // Which write is running, so each button shows its own progress. + const [writing, setWriting] = useState<"save" | "off" | null>(null); + const [failed, setFailed] = useState(false); + const busy = writing !== null; + // The system back on Android is Cancel, and like Cancel it waits while a + // write runs. + useEffect(() => { + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + if (!busy) onCancel(); + return true; + }); + return () => subscription.remove(); + }, [busy, onCancel]); + + const normalized = normalizePoiProfile(draft); + const issues = poiProfileIssues(draft); + // Empty fields disable Save without an error: an empty form is a state. + const error = (field: PoiProfileField) => + normalized[field] !== "" && issues.includes(field) ? shapeOf(field, strings) : undefined; + + const write = async (kind: "save" | "off") => { + setWriting(kind); + setFailed(false); + try { + await onSave(kind === "save" ? normalized : null); + } catch { + setFailed(true); + } finally { + setWriting(null); + } + }; + + return ( + +
+ + + {strings.poi.explainer} + + setDraft({ ...draft, domain })} + placeholder={strings.poi.domainPlaceholder} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + + setDraft({ ...draft, providerId })} + placeholder={strings.poi.providerPlaceholder} + autoCapitalize="characters" + autoCorrect={false} + maxLength={8} + /> + + + setDraft({ ...draft, issuer })} + placeholder={strings.poi.issuerPlaceholder} + autoCapitalize="characters" + autoCorrect={false} + maxLength={8} + /> + + + + {failed ? ( + + {strings.poi.saveFailed} + + ) : null} + + +