ksef-client-rails 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- metadata +291 -9
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
|
|
3
|
+
<Naglowek>
|
|
4
|
+
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
|
|
5
|
+
<WariantFormularza>3</WariantFormularza>
|
|
6
|
+
<DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
|
|
7
|
+
<SystemInfo>ksef-client-rails</SystemInfo>
|
|
8
|
+
</Naglowek>
|
|
9
|
+
<Podmiot1>
|
|
10
|
+
<DaneIdentyfikacyjne>
|
|
11
|
+
<NIP>5555555555</NIP>
|
|
12
|
+
<Nazwa>Seller Ltd.</Nazwa>
|
|
13
|
+
</DaneIdentyfikacyjne>
|
|
14
|
+
<Adres>
|
|
15
|
+
<KodKraju>PL</KodKraju>
|
|
16
|
+
<AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
|
|
17
|
+
</Adres>
|
|
18
|
+
<DaneKontaktowe>
|
|
19
|
+
<Email>invoices@seller.example</Email>
|
|
20
|
+
<Telefon>+48 22 000 00 00</Telefon>
|
|
21
|
+
</DaneKontaktowe>
|
|
22
|
+
</Podmiot1>
|
|
23
|
+
<Podmiot2>
|
|
24
|
+
<DaneIdentyfikacyjne>
|
|
25
|
+
<NIP>1111111111</NIP>
|
|
26
|
+
<Nazwa>Buyer Inc.</Nazwa>
|
|
27
|
+
</DaneIdentyfikacyjne>
|
|
28
|
+
<Adres>
|
|
29
|
+
<KodKraju>PL</KodKraju>
|
|
30
|
+
<AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
|
|
31
|
+
</Adres>
|
|
32
|
+
<DaneKontaktowe>
|
|
33
|
+
<Email>accounts@buyer.example</Email>
|
|
34
|
+
</DaneKontaktowe>
|
|
35
|
+
<JST>2</JST>
|
|
36
|
+
<GV>2</GV>
|
|
37
|
+
</Podmiot2>
|
|
38
|
+
<Fa>
|
|
39
|
+
<KodWaluty>PLN</KodWaluty>
|
|
40
|
+
<P_1>2026-02-01</P_1>
|
|
41
|
+
<P_1M>Warsaw</P_1M>
|
|
42
|
+
<P_2>FV/2026/02/1</P_2>
|
|
43
|
+
<P_6>2026-02-01</P_6>
|
|
44
|
+
<P_13_1>1900.00</P_13_1>
|
|
45
|
+
<P_14_1>437.00</P_14_1>
|
|
46
|
+
<P_13_2>120.00</P_13_2>
|
|
47
|
+
<P_14_2>9.60</P_14_2>
|
|
48
|
+
<P_13_3>50.00</P_13_3>
|
|
49
|
+
<P_14_3>2.50</P_14_3>
|
|
50
|
+
<P_13_6_1>20.00</P_13_6_1>
|
|
51
|
+
<P_13_7>30.00</P_13_7>
|
|
52
|
+
<P_15>2569.10</P_15>
|
|
53
|
+
<Adnotacje>
|
|
54
|
+
<P_16>2</P_16>
|
|
55
|
+
<P_17>2</P_17>
|
|
56
|
+
<P_18>2</P_18>
|
|
57
|
+
<P_18A>2</P_18A>
|
|
58
|
+
<Zwolnienie>
|
|
59
|
+
<P_19>1</P_19>
|
|
60
|
+
<P_19A>Art. 43 ust. 1 pkt 37 ustawy o VAT</P_19A>
|
|
61
|
+
</Zwolnienie>
|
|
62
|
+
<NoweSrodkiTransportu>
|
|
63
|
+
<P_22N>1</P_22N>
|
|
64
|
+
</NoweSrodkiTransportu>
|
|
65
|
+
<P_23>2</P_23>
|
|
66
|
+
<PMarzy>
|
|
67
|
+
<P_PMarzyN>1</P_PMarzyN>
|
|
68
|
+
</PMarzy>
|
|
69
|
+
</Adnotacje>
|
|
70
|
+
<RodzajFaktury>VAT</RodzajFaktury>
|
|
71
|
+
<FaWiersz>
|
|
72
|
+
<NrWierszaFa>1</NrWierszaFa>
|
|
73
|
+
<P_7>Software licence</P_7>
|
|
74
|
+
<Indeks>LIC-01</Indeks>
|
|
75
|
+
<GTIN>5901234123457</GTIN>
|
|
76
|
+
<PKWiU>58.29.50.0</PKWiU>
|
|
77
|
+
<CN>8523</CN>
|
|
78
|
+
<P_8A>szt.</P_8A>
|
|
79
|
+
<P_8B>2</P_8B>
|
|
80
|
+
<P_9A>1000.00</P_9A>
|
|
81
|
+
<P_10>100.00</P_10>
|
|
82
|
+
<P_11>1900.00</P_11>
|
|
83
|
+
<P_12>23</P_12>
|
|
84
|
+
<GTU>GTU_12</GTU>
|
|
85
|
+
</FaWiersz>
|
|
86
|
+
<FaWiersz>
|
|
87
|
+
<NrWierszaFa>2</NrWierszaFa>
|
|
88
|
+
<P_7>Printed manual</P_7>
|
|
89
|
+
<P_8A>szt.</P_8A>
|
|
90
|
+
<P_8B>1</P_8B>
|
|
91
|
+
<P_9A>50.00</P_9A>
|
|
92
|
+
<P_11>50.00</P_11>
|
|
93
|
+
<P_12>5</P_12>
|
|
94
|
+
</FaWiersz>
|
|
95
|
+
<FaWiersz>
|
|
96
|
+
<NrWierszaFa>3</NrWierszaFa>
|
|
97
|
+
<P_7>Catering</P_7>
|
|
98
|
+
<P_8A>szt.</P_8A>
|
|
99
|
+
<P_8B>3</P_8B>
|
|
100
|
+
<P_9A>40.00</P_9A>
|
|
101
|
+
<P_11>120.00</P_11>
|
|
102
|
+
<P_12>8</P_12>
|
|
103
|
+
</FaWiersz>
|
|
104
|
+
<FaWiersz>
|
|
105
|
+
<NrWierszaFa>4</NrWierszaFa>
|
|
106
|
+
<P_7>Zero-rated supply</P_7>
|
|
107
|
+
<P_8A>szt.</P_8A>
|
|
108
|
+
<P_8B>1</P_8B>
|
|
109
|
+
<P_9A>20.00</P_9A>
|
|
110
|
+
<P_11>20.00</P_11>
|
|
111
|
+
<P_12>0 KR</P_12>
|
|
112
|
+
</FaWiersz>
|
|
113
|
+
<FaWiersz>
|
|
114
|
+
<NrWierszaFa>5</NrWierszaFa>
|
|
115
|
+
<P_7>Insurance</P_7>
|
|
116
|
+
<P_8A>szt.</P_8A>
|
|
117
|
+
<P_8B>1</P_8B>
|
|
118
|
+
<P_9A>30.00</P_9A>
|
|
119
|
+
<P_11>30.00</P_11>
|
|
120
|
+
<P_12>zw</P_12>
|
|
121
|
+
</FaWiersz>
|
|
122
|
+
<Platnosc>
|
|
123
|
+
<TerminPlatnosci>
|
|
124
|
+
<Termin>2026-02-15</Termin>
|
|
125
|
+
</TerminPlatnosci>
|
|
126
|
+
<FormaPlatnosci>6</FormaPlatnosci>
|
|
127
|
+
<RachunekBankowy>
|
|
128
|
+
<NrRB>17100000000000000000000001</NrRB>
|
|
129
|
+
<NazwaBanku>Example Bank</NazwaBanku>
|
|
130
|
+
</RachunekBankowy>
|
|
131
|
+
</Platnosc>
|
|
132
|
+
</Fa>
|
|
133
|
+
<Stopka>
|
|
134
|
+
<Informacje>
|
|
135
|
+
<StopkaFaktury>Thank you for your business.</StopkaFaktury>
|
|
136
|
+
</Informacje>
|
|
137
|
+
<Rejestry>
|
|
138
|
+
<KRS>0000123456</KRS>
|
|
139
|
+
<REGON>123456785</REGON>
|
|
140
|
+
<BDO>000012345</BDO>
|
|
141
|
+
</Rejestry>
|
|
142
|
+
</Stopka>
|
|
143
|
+
</Faktura>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
|
|
3
|
+
<Naglowek>
|
|
4
|
+
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
|
|
5
|
+
<WariantFormularza>3</WariantFormularza>
|
|
6
|
+
<DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
|
|
7
|
+
<SystemInfo>ksef-client-rails</SystemInfo>
|
|
8
|
+
</Naglowek>
|
|
9
|
+
<Podmiot1>
|
|
10
|
+
<DaneIdentyfikacyjne>
|
|
11
|
+
<NIP>5555555555</NIP>
|
|
12
|
+
<Nazwa>Seller Ltd.</Nazwa>
|
|
13
|
+
</DaneIdentyfikacyjne>
|
|
14
|
+
<Adres>
|
|
15
|
+
<KodKraju>PL</KodKraju>
|
|
16
|
+
<AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
|
|
17
|
+
</Adres>
|
|
18
|
+
</Podmiot1>
|
|
19
|
+
<Podmiot2>
|
|
20
|
+
<DaneIdentyfikacyjne>
|
|
21
|
+
<NIP>1111111111</NIP>
|
|
22
|
+
<Nazwa>Buyer Inc.</Nazwa>
|
|
23
|
+
</DaneIdentyfikacyjne>
|
|
24
|
+
<Adres>
|
|
25
|
+
<KodKraju>PL</KodKraju>
|
|
26
|
+
<AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
|
|
27
|
+
</Adres>
|
|
28
|
+
<JST>2</JST>
|
|
29
|
+
<GV>2</GV>
|
|
30
|
+
</Podmiot2>
|
|
31
|
+
<Fa>
|
|
32
|
+
<KodWaluty>PLN</KodWaluty>
|
|
33
|
+
<P_1>2026-02-01</P_1>
|
|
34
|
+
<P_2>FV/2026/02/1</P_2>
|
|
35
|
+
<P_13_1>100.00</P_13_1>
|
|
36
|
+
<P_14_1>23.00</P_14_1>
|
|
37
|
+
<P_15>123.00</P_15>
|
|
38
|
+
<Adnotacje>
|
|
39
|
+
<P_16>2</P_16>
|
|
40
|
+
<P_17>2</P_17>
|
|
41
|
+
<P_18>2</P_18>
|
|
42
|
+
<P_18A>2</P_18A>
|
|
43
|
+
<Zwolnienie>
|
|
44
|
+
<P_19N>1</P_19N>
|
|
45
|
+
</Zwolnienie>
|
|
46
|
+
<NoweSrodkiTransportu>
|
|
47
|
+
<P_22N>1</P_22N>
|
|
48
|
+
</NoweSrodkiTransportu>
|
|
49
|
+
<P_23>2</P_23>
|
|
50
|
+
<PMarzy>
|
|
51
|
+
<P_PMarzyN>1</P_PMarzyN>
|
|
52
|
+
</PMarzy>
|
|
53
|
+
</Adnotacje>
|
|
54
|
+
<RodzajFaktury>VAT</RodzajFaktury>
|
|
55
|
+
<FaWiersz>
|
|
56
|
+
<NrWierszaFa>1</NrWierszaFa>
|
|
57
|
+
<P_7>Demo service</P_7>
|
|
58
|
+
<P_8A>szt.</P_8A>
|
|
59
|
+
<P_8B>1</P_8B>
|
|
60
|
+
<P_9A>100.00</P_9A>
|
|
61
|
+
<P_11>100.00</P_11>
|
|
62
|
+
<P_12>23</P_12>
|
|
63
|
+
</FaWiersz>
|
|
64
|
+
</Fa>
|
|
65
|
+
</Faktura>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
|
|
3
|
+
<Naglowek>
|
|
4
|
+
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
|
|
5
|
+
<WariantFormularza>3</WariantFormularza>
|
|
6
|
+
<DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
|
|
7
|
+
<SystemInfo>ksef-client-rails</SystemInfo>
|
|
8
|
+
</Naglowek>
|
|
9
|
+
<Podmiot1>
|
|
10
|
+
<DaneIdentyfikacyjne>
|
|
11
|
+
<NIP>5555555555</NIP>
|
|
12
|
+
<Nazwa>Seller Ltd.</Nazwa>
|
|
13
|
+
</DaneIdentyfikacyjne>
|
|
14
|
+
<Adres>
|
|
15
|
+
<KodKraju>PL</KodKraju>
|
|
16
|
+
<AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
|
|
17
|
+
</Adres>
|
|
18
|
+
</Podmiot1>
|
|
19
|
+
<Podmiot2>
|
|
20
|
+
<DaneIdentyfikacyjne>
|
|
21
|
+
<NIP>1111111111</NIP>
|
|
22
|
+
<Nazwa>Buyer Inc.</Nazwa>
|
|
23
|
+
</DaneIdentyfikacyjne>
|
|
24
|
+
<Adres>
|
|
25
|
+
<KodKraju>PL</KodKraju>
|
|
26
|
+
<AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
|
|
27
|
+
</Adres>
|
|
28
|
+
<JST>2</JST>
|
|
29
|
+
<GV>2</GV>
|
|
30
|
+
</Podmiot2>
|
|
31
|
+
<Fa>
|
|
32
|
+
<KodWaluty>PLN</KodWaluty>
|
|
33
|
+
<P_1>2026-02-01</P_1>
|
|
34
|
+
<P_1M>Warsaw</P_1M>
|
|
35
|
+
<P_2>FV/2026/02/1</P_2>
|
|
36
|
+
<P_6>2026-02-01</P_6>
|
|
37
|
+
<P_13_1>100.00</P_13_1>
|
|
38
|
+
<P_14_1>23.00</P_14_1>
|
|
39
|
+
<P_15>123.00</P_15>
|
|
40
|
+
<Adnotacje>
|
|
41
|
+
<P_16>2</P_16>
|
|
42
|
+
<P_17>2</P_17>
|
|
43
|
+
<P_18>2</P_18>
|
|
44
|
+
<P_18A>2</P_18A>
|
|
45
|
+
<Zwolnienie>
|
|
46
|
+
<P_19N>1</P_19N>
|
|
47
|
+
</Zwolnienie>
|
|
48
|
+
<NoweSrodkiTransportu>
|
|
49
|
+
<P_22N>1</P_22N>
|
|
50
|
+
</NoweSrodkiTransportu>
|
|
51
|
+
<P_23>2</P_23>
|
|
52
|
+
<PMarzy>
|
|
53
|
+
<P_PMarzyN>1</P_PMarzyN>
|
|
54
|
+
</PMarzy>
|
|
55
|
+
</Adnotacje>
|
|
56
|
+
<RodzajFaktury>VAT</RodzajFaktury>
|
|
57
|
+
<FaWiersz>
|
|
58
|
+
<NrWierszaFa>1</NrWierszaFa>
|
|
59
|
+
<P_7>Demo service</P_7>
|
|
60
|
+
<P_8A>szt.</P_8A>
|
|
61
|
+
<P_8B>1</P_8B>
|
|
62
|
+
<P_9A>100.00</P_9A>
|
|
63
|
+
<P_11>100.00</P_11>
|
|
64
|
+
<P_12>23</P_12>
|
|
65
|
+
</FaWiersz>
|
|
66
|
+
<Platnosc>
|
|
67
|
+
<TerminPlatnosci>
|
|
68
|
+
<Termin>2026-02-15</Termin>
|
|
69
|
+
</TerminPlatnosci>
|
|
70
|
+
<FormaPlatnosci>6</FormaPlatnosci>
|
|
71
|
+
<RachunekBankowy>
|
|
72
|
+
<NrRB>17100000000000000000000001</NrRB>
|
|
73
|
+
</RachunekBankowy>
|
|
74
|
+
</Platnosc>
|
|
75
|
+
</Fa>
|
|
76
|
+
</Faktura>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Generators
|
|
7
|
+
# Install generator: `rails g ksef:install`.
|
|
8
|
+
# Creates `config/initializers/ksef.rb` with pointers to Rails credentials.
|
|
9
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Creates the KSeF initializer at config/initializers/ksef.rb"
|
|
13
|
+
|
|
14
|
+
# Copies the initializer template into the application.
|
|
15
|
+
def copy_initializer
|
|
16
|
+
template "ksef.rb", "config/initializers/ksef.rb"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# KSeF client configuration (the `ksef-client-rails` gem) — https://github.com/CIRFMF/ksef-api
|
|
4
|
+
# Keep the KSeF token and certificates in Rails credentials, never in the repository.
|
|
5
|
+
# This file runs after `config.ksef.*` from config/application.rb (railtie), so whatever it sets wins.
|
|
6
|
+
|
|
7
|
+
Ksef.configure do |config|
|
|
8
|
+
# Environment: :test | :demo | :prod — set it explicitly for every deployment. Never derive it from
|
|
9
|
+
# Rails.env: staging usually runs with RAILS_ENV=production, and every invoice sent to :prod is
|
|
10
|
+
# legally binding. Without KSEF_ENV (or with it empty) the environment stays as configured (default :test).
|
|
11
|
+
config.environment = ENV["KSEF_ENV"].presence || config.environment
|
|
12
|
+
|
|
13
|
+
# Timeouts in seconds (defaults: 60 and 15):
|
|
14
|
+
# config.timeout = 60
|
|
15
|
+
# config.open_timeout = 15
|
|
16
|
+
|
|
17
|
+
# The railtie logs to Rails.logger; nil turns logging off:
|
|
18
|
+
# config.logger = nil
|
|
19
|
+
|
|
20
|
+
# A 429 asking to wait longer than this (seconds) raises Ksef::RateLimited instead of sleeping:
|
|
21
|
+
# config.max_retry_after = 60
|
|
22
|
+
|
|
23
|
+
# API feature flags sent as X-KSeF-Feature (e.g. "subject-identifier-validation" on TEST/DEMO):
|
|
24
|
+
# config.ksef_features = %w[]
|
|
25
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Auth
|
|
5
|
+
# Authentication coordinator: orchestrates the full flow
|
|
6
|
+
# challenge → signature|token → POST → polling GET /auth/{ref} → redeem → (refresh).
|
|
7
|
+
#
|
|
8
|
+
# Polling returns `status.code` inside a 200 body (NOT an HTTP code): 100 in progress,
|
|
9
|
+
# 200 OK, everything else is an error — see {Ksef::AuthenticationFailed}.
|
|
10
|
+
class Coordinator
|
|
11
|
+
# The "operation in progress" code.
|
|
12
|
+
STATUS_IN_PROGRESS = 100
|
|
13
|
+
# The "authenticated" code.
|
|
14
|
+
STATUS_SUCCESS = 200
|
|
15
|
+
|
|
16
|
+
# Context key → AuthenticationContextIdentifierType enum value (PascalCase).
|
|
17
|
+
CONTEXT_TYPES = {
|
|
18
|
+
nip: "Nip",
|
|
19
|
+
internal_id: "InternalId",
|
|
20
|
+
nip_vat_ue: "NipVatUe",
|
|
21
|
+
peppol_id: "PeppolId"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Authentication result: the token pair plus the auth session reference number.
|
|
25
|
+
Result = Struct.new(:reference_number, :access_token, :access_token_valid_until,
|
|
26
|
+
:refresh_token, :refresh_token_valid_until, keyword_init: true) do
|
|
27
|
+
# Tokens never go to error pages, `pp` output or exception reports.
|
|
28
|
+
# @return [String]
|
|
29
|
+
def inspect
|
|
30
|
+
"#<#{self.class.name} reference_number=#{reference_number.inspect} " \
|
|
31
|
+
"access_token=[REDACTED] access_token_valid_until=#{access_token_valid_until.inspect} " \
|
|
32
|
+
"refresh_token=[REDACTED] refresh_token_valid_until=#{refresh_token_valid_until.inspect}>"
|
|
33
|
+
end
|
|
34
|
+
alias_method :to_s, :inspect
|
|
35
|
+
|
|
36
|
+
# `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
|
|
37
|
+
def pretty_print(printer)
|
|
38
|
+
printer.text(inspect)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param client [Ksef::Client]
|
|
43
|
+
def initialize(client)
|
|
44
|
+
@client = client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Authenticates with an XAdES signature (local certificate or an external signer).
|
|
48
|
+
#
|
|
49
|
+
# @param context [Hash] one of {nip:}, {internal_id:}, {nip_vat_ue:}, {peppol_id:}; a NIP is
|
|
50
|
+
# normalized ({Ksef::Nip.normalize!}) and an invalid one raises Ksef::ConfigurationError
|
|
51
|
+
# @param signer [#sign, #call] {Ksef::Xades::Signer} or a lambda signing the XML
|
|
52
|
+
# @param subject_identifier_type [Symbol] :certificate_subject|:certificate_fingerprint
|
|
53
|
+
# @param allowed_ips [Hash, nil] {addresses:, ranges:, masks:}
|
|
54
|
+
# @param verify_certificate_chain [Boolean]
|
|
55
|
+
# @param poll [Hash] polling options (timeout:, interval:, ...)
|
|
56
|
+
# @return [Result]
|
|
57
|
+
# @raise [Ksef::ConfigurationError] for an invalid context, subject type, allowed_ips or signer — before
|
|
58
|
+
# any request
|
|
59
|
+
def authenticate(context:, signer:, subject_identifier_type: :certificate_subject,
|
|
60
|
+
allowed_ips: nil, verify_certificate_chain: false, poll: {})
|
|
61
|
+
signer = Xades::ExternalSigner.wrap(signer) # an unusable signer fails before any request
|
|
62
|
+
request = Xades::AuthTokenRequest.new(context: context, subject_identifier_type: subject_identifier_type,
|
|
63
|
+
allowed_ips: allowed_ips)
|
|
64
|
+
request.challenge = @client.auth.challenge["challenge"]
|
|
65
|
+
signed = Xades::ExternalSigner.signed!(signer.sign(request.to_xml))
|
|
66
|
+
init = @client.auth.submit_xades(signed, verify_certificate_chain: verify_certificate_chain)
|
|
67
|
+
finalize(init, poll)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Authenticates with an encrypted KSeF token (RSA-OAEP).
|
|
71
|
+
#
|
|
72
|
+
# @param context [Hash] one of {nip:}, {internal_id:}, {nip_vat_ue:}, {peppol_id:}; a NIP is
|
|
73
|
+
# normalized ({Ksef::Nip.normalize!}) and an invalid one raises Ksef::ConfigurationError
|
|
74
|
+
# @param token [String] KSeF token (secret)
|
|
75
|
+
# @param poll [Hash] polling options
|
|
76
|
+
# @return [Result]
|
|
77
|
+
def authenticate_with_token(context:, token:, poll: {})
|
|
78
|
+
type, value = context_type_and_value(context)
|
|
79
|
+
encryptor = KsefTokenEncryptor.new(crypto: @client.crypto, public_keys: @client.public_keys)
|
|
80
|
+
# A retry after key rotation starts over with a fresh challenge.
|
|
81
|
+
init = @client.public_keys.with_rotation_retry do
|
|
82
|
+
challenge = @client.auth.challenge
|
|
83
|
+
encrypted = encryptor.encrypt(token: token, timestamp_ms: challenge["timestampMs"])
|
|
84
|
+
@client.auth.submit_ksef_token(
|
|
85
|
+
challenge: challenge["challenge"],
|
|
86
|
+
context_type: type,
|
|
87
|
+
context_value: value,
|
|
88
|
+
encrypted_token: encrypted.encrypted_token,
|
|
89
|
+
public_key_id: encrypted.public_key_id
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
finalize(init, poll)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Refreshes the accessToken using the refresh token.
|
|
96
|
+
# @param refresh_token [String]
|
|
97
|
+
# @return [Hash] { "accessToken" => {token, validUntil} }
|
|
98
|
+
def refresh(refresh_token:)
|
|
99
|
+
@client.auth.refresh(refresh_token: refresh_token)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def finalize(init, poll_opts)
|
|
105
|
+
reference_number = init["referenceNumber"]
|
|
106
|
+
auth_token = token!(init, "authenticationToken", "the authentication init")
|
|
107
|
+
wait_until_authenticated(reference_number, auth_token, poll_opts)
|
|
108
|
+
|
|
109
|
+
tokens = @client.auth.redeem(authentication_token: auth_token)
|
|
110
|
+
Result.new(
|
|
111
|
+
reference_number: reference_number,
|
|
112
|
+
access_token: token!(tokens, "accessToken", "POST /auth/token/redeem"),
|
|
113
|
+
access_token_valid_until: tokens.dig("accessToken", "validUntil"),
|
|
114
|
+
refresh_token: token!(tokens, "refreshToken", "POST /auth/token/redeem"),
|
|
115
|
+
refresh_token_valid_until: tokens.dig("refreshToken", "validUntil")
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def token!(response, key, operation)
|
|
120
|
+
token = response[key].is_a?(Hash) ? response[key]["token"] : nil
|
|
121
|
+
return token unless token.to_s.empty?
|
|
122
|
+
|
|
123
|
+
raise ApiError, "#{operation}: KSeF's response lacks #{key}.token"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def wait_until_authenticated(reference_number, auth_token, poll_opts)
|
|
127
|
+
Polling.run(description: "auth #{reference_number}", logger: @client.config.logger, **poll_opts) do
|
|
128
|
+
response = @client.auth.status(reference_number, authentication_token: auth_token)
|
|
129
|
+
interpret_status(response)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def interpret_status(response)
|
|
134
|
+
status = response["status"] || {}
|
|
135
|
+
case status["code"]
|
|
136
|
+
when STATUS_IN_PROGRESS
|
|
137
|
+
Polling.pending
|
|
138
|
+
when STATUS_SUCCESS
|
|
139
|
+
response
|
|
140
|
+
else
|
|
141
|
+
raise AuthenticationFailed.new(
|
|
142
|
+
status["description"] || "Authentication failed",
|
|
143
|
+
code: status["code"],
|
|
144
|
+
messages: status["details"]
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def context_type_and_value(context)
|
|
150
|
+
unless context.is_a?(Hash)
|
|
151
|
+
raise ConfigurationError, "context must be a Hash with exactly one of: #{CONTEXT_TYPES.keys.join(', ')}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
pairs = context.transform_keys(&:to_sym)
|
|
155
|
+
key, value = pairs.first
|
|
156
|
+
unless pairs.size == 1 && CONTEXT_TYPES.key?(key)
|
|
157
|
+
raise ConfigurationError, "context must contain exactly one of: #{CONTEXT_TYPES.keys.join(', ')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
[CONTEXT_TYPES.fetch(key), key == :nip ? Nip.normalize!(value, name: "context nip") : value.to_s]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Auth
|
|
7
|
+
# Encrypts the KSeF token for token authentication: `"#{token}|#{timestampMs}"`
|
|
8
|
+
# wrapped with RSA-OAEP(SHA-256), Base64. The timestamp must come from `/auth/challenge`
|
|
9
|
+
# (epoch MILLISECONDS), never the local clock. ECIES is out of scope: undocumented in the API
|
|
10
|
+
# docs and assembled incompatibly between the two SDKs.
|
|
11
|
+
class KsefTokenEncryptor
|
|
12
|
+
# @param crypto [Ksef::Crypto::CryptographyService]
|
|
13
|
+
# @param public_keys [Ksef::Crypto::PublicKeys]
|
|
14
|
+
def initialize(crypto:, public_keys:)
|
|
15
|
+
@crypto = crypto
|
|
16
|
+
@public_keys = public_keys
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Encryption result: Base64 token + public key identifier.
|
|
20
|
+
Result = Struct.new(:encrypted_token, :public_key_id, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# @param token [String] KSeF token (secret)
|
|
23
|
+
# @param timestamp_ms [Integer] timestampMs from /auth/challenge
|
|
24
|
+
# @return [Result]
|
|
25
|
+
def encrypt(token:, timestamp_ms:)
|
|
26
|
+
cert = @public_keys.for_usage(Crypto::PublicKeys::KSEF_TOKEN_ENCRYPTION)
|
|
27
|
+
payload = "#{token}|#{timestamp_ms}"
|
|
28
|
+
ciphertext = @crypto.rsa_oaep_sha256(cert.public_key, payload)
|
|
29
|
+
Result.new(
|
|
30
|
+
encrypted_token: Base64.strict_encode64(ciphertext),
|
|
31
|
+
public_key_id: cert.public_key_id
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Batch
|
|
7
|
+
# Builds the batch package (ZIP or tar.gz) plus a local hash→file map for correlating statuses.
|
|
8
|
+
# File names stay local — `BatchFilePartInfo` has no `fileName`. A tar entry name must fit the
|
|
9
|
+
# 100-byte USTAR name field: a longer one would need a GNU long-name record, which KSeF is not known to read.
|
|
10
|
+
class PackageBuilder
|
|
11
|
+
# The built package: raw (plaintext) bytes + a name→{hash,size} map of the files inside, keyed by
|
|
12
|
+
# the name in the package.
|
|
13
|
+
Package = Struct.new(:bytes, :files, keyword_init: true) do
|
|
14
|
+
# The package size and file count — never its bytes.
|
|
15
|
+
# @return [String]
|
|
16
|
+
def inspect
|
|
17
|
+
"#<#{self.class.name} bytes=#{bytes.to_s.bytesize} files=#{files.to_h.size}>"
|
|
18
|
+
end
|
|
19
|
+
alias_method :to_s, :inspect
|
|
20
|
+
|
|
21
|
+
# `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
|
|
22
|
+
def pretty_print(printer)
|
|
23
|
+
printer.text(inspect)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Longest tar entry name, in bytes (the USTAR `name` field).
|
|
28
|
+
TAR_NAME_LIMIT = 100
|
|
29
|
+
|
|
30
|
+
# ZIP general-purpose flag bit 11 (APPNOTE 4.4.4): the entry name is UTF-8. rubyzip sets it only
|
|
31
|
+
# under the global `Zip.unicode_names`, which would change rubyzip for the whole process.
|
|
32
|
+
UTF8_NAME_FLAG = 0x0800
|
|
33
|
+
|
|
34
|
+
# Builds a ZIP package.
|
|
35
|
+
# @param invoices [Hash{String=>String}, Array<String>] invoices (name→XML or a list of XML)
|
|
36
|
+
# @return [Package]
|
|
37
|
+
# @raise [Ksef::ConfigurationError] for no invoices, or two names that are the same in the package
|
|
38
|
+
def self.zip(invoices)
|
|
39
|
+
require "zip"
|
|
40
|
+
named = normalize(invoices)
|
|
41
|
+
buffer = Zip::OutputStream.write_buffer do |zos|
|
|
42
|
+
named.each do |name, xml|
|
|
43
|
+
zos.put_next_entry(zip_entry(name))
|
|
44
|
+
zos.write(xml)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
Package.new(bytes: buffer.string, files: files_map(named))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a tar.gz package (requires the `minitar` gem).
|
|
51
|
+
# @param invoices [Hash{String=>String}, Array<String>]
|
|
52
|
+
# @return [Package]
|
|
53
|
+
# @raise [Ksef::ConfigurationError] when minitar is not installed, for no invoices, two names that are
|
|
54
|
+
# the same in the package, or a name over {TAR_NAME_LIMIT} bytes
|
|
55
|
+
def self.tar_gz(invoices)
|
|
56
|
+
Ksef.require_optional("minitar", "A tar.gz package")
|
|
57
|
+
require "zlib"
|
|
58
|
+
named = normalize(invoices)
|
|
59
|
+
too_long = named.keys.select { |name| name.bytesize > TAR_NAME_LIMIT }
|
|
60
|
+
unless too_long.empty?
|
|
61
|
+
raise ConfigurationError, "tar entry names are limited to #{TAR_NAME_LIMIT} bytes: #{too_long.join(', ')}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Package.new(bytes: tar_bytes(named), files: files_map(named))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.tar_bytes(named)
|
|
68
|
+
io = StringIO.new("".b)
|
|
69
|
+
Zlib::GzipWriter.wrap(io) do |gz|
|
|
70
|
+
Minitar::Writer.open(gz) do |tar|
|
|
71
|
+
named.each do |name, xml|
|
|
72
|
+
tar.add_file_simple(name.b, mode: 0o644, size: xml.bytesize) { |stream| stream.write(xml) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
io.string
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.zip_entry(name)
|
|
80
|
+
entry = Zip::Entry.new("", name)
|
|
81
|
+
entry.gp_flags |= UTF8_NAME_FLAG unless name.ascii_only?
|
|
82
|
+
entry
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Normalizes the input into a name→XML map with the names as they go into the package: UTF-8, with
|
|
86
|
+
# `/` for a backslash (auto-generated names for a list).
|
|
87
|
+
def self.normalize(invoices)
|
|
88
|
+
raise ConfigurationError, "a batch package needs at least one invoice" if invoices.nil? || invoices.empty?
|
|
89
|
+
return invoices.each_with_index.to_h { |xml, i| [format("invoice-%04d.xml", i + 1), xml] } unless
|
|
90
|
+
invoices.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
named = invoices.to_h { |name, xml| [package_name(name), xml] }
|
|
93
|
+
return named if named.size == invoices.size
|
|
94
|
+
|
|
95
|
+
duplicates = invoices.keys.group_by { |name| package_name(name) }.select { |_, keys| keys.size > 1 }
|
|
96
|
+
raise ConfigurationError, "invoice names collide in the package: #{duplicates.values.map(&:inspect).join('; ')}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.package_name(name)
|
|
100
|
+
text = name.to_s
|
|
101
|
+
text = text.encode(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
102
|
+
raise ConfigurationError, "invoice name #{text.inspect} is not valid UTF-8" unless text.valid_encoding?
|
|
103
|
+
raise ConfigurationError, "invoice names must not be blank" if text.strip.empty?
|
|
104
|
+
|
|
105
|
+
name = text.tr("\\", "/")
|
|
106
|
+
return name unless name.start_with?("/") || name.split("/").include?("..")
|
|
107
|
+
|
|
108
|
+
raise ConfigurationError, "invoice name #{text.inspect} must not be absolute or contain a .. segment"
|
|
109
|
+
rescue EncodingError
|
|
110
|
+
raise ConfigurationError, "invoice name #{name.inspect} cannot be converted to UTF-8"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.files_map(named)
|
|
114
|
+
require "digest"
|
|
115
|
+
require "base64"
|
|
116
|
+
named.transform_values do |xml|
|
|
117
|
+
bytes = xml.b
|
|
118
|
+
{ "fileSize" => bytes.bytesize, "fileHash" => Base64.strict_encode64(Digest::SHA256.digest(bytes)) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private_class_method :tar_bytes, :zip_entry, :normalize, :package_name, :files_map
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|