Passed
Push — master ( df9557...e5317d )
by Jan
04:48 queued 11s
created

exportSinglePaymentOrder()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 40
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 22
nc 5
nop 4
dl 0
loc 40
rs 8.4444
c 1
b 0
f 0
1
<?php
2
/*
3
 * Copyright (C) 2020  Jan Böhmer
4
 *
5
 * This program is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published
7
 * by the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
17
 */
18
19
namespace App\Services;
20
21
use App\Entity\BankAccount;
22
use App\Entity\PaymentOrder;
23
use App\Exception\SEPAExportAutoModeNotPossible;
24
use Digitick\Sepa\DomBuilder\BaseDomBuilder;
25
use Digitick\Sepa\DomBuilder\DomBuilderFactory;
26
use Digitick\Sepa\GroupHeader;
27
use Digitick\Sepa\PaymentInformation;
28
use Digitick\Sepa\TransferFile\CustomerCreditTransferFile;
29
use Digitick\Sepa\TransferInformation\CustomerCreditTransferInformation;
30
use Doctrine\ORM\EntityManagerInterface;
31
use InvalidArgumentException;
32
use RuntimeException;
33
use Symfony\Component\OptionsResolver\Options;
34
use Symfony\Component\OptionsResolver\OptionsResolver;
35
36
/**
37
 * This service allows to create a SEPA-XML file from a payment order that can be used to import it in an online
38
 * banking system.
39
 */
40
class PaymentOrdersSEPAExporter
41
{
42
    protected const PARTY_NAME = 'StuRa FSU Jena';
43
    protected const ID_PREFIX = 'StuRa Export';
44
    protected const PAYMENT_PREFIX = 'Payment';
45
46
    protected $fsr_kom_bank_account_id;
47
    protected $entityManager;
48
49
    public function __construct(int $fsr_kom_bank_account_id, EntityManagerInterface $entityManager)
50
    {
51
        $this->fsr_kom_bank_account_id = $fsr_kom_bank_account_id;
52
        $this->entityManager = $entityManager;
53
    }
54
55
    /**
56
     * Exports the given paymentOrders as SEPA-XML files.
57
     *
58
     * @throws \Digitick\Sepa\Exception\InvalidArgumentException
59
     */
60
    public function export(array $payment_orders, array $options): array
61
    {
62
        $resolver = new OptionsResolver();
63
        $this->configureOptions($resolver);
64
        $options = $resolver->resolve($options);
65
66
        $accounts = [];
67
        $return = [];
68
69
        if ('manual' === $options['mode']) {
70
            $accounts[0] = [
71
                'iban' => $options['iban'],
72
                'bic' => $options['bic'],
73
                'name' => $options['name'],
74
                'entries' => $payment_orders,
75
            ];
76
        } elseif ('auto' === $options['mode']) {
77
            $accounts = $this->groupByBankAccounts($payment_orders);
78
        } elseif ('auto_single' === $options['mode']) {
79
            //Export every payment order separately and return early
80
            foreach ($payment_orders as $payment_order) {
81
                $return[$payment_order->getIDString()] = $this->exportSinglePaymentOrder($payment_order);
82
            }
83
84
            return $return;
85
        } else {
86
            throw new RuntimeException('Unknown mode');
87
        }
88
89
        foreach ($accounts as $account_info) {
90
            $groupHeader = $this->getGroupHeader();
91
            $sepaFile = new CustomerCreditTransferFile($groupHeader);
92
93
            // A single payment info where all PaymentOrders are added as transactions
94
            $payment = new PaymentInformation(
95
                static::PAYMENT_PREFIX.' '.uniqid('', false),
96
                $account_info['iban'],
97
                $account_info['bic'],
98
                $account_info['name']
99
            );
100
101
            $this->addPaymentOrderTransactions($payment, $account_info['entries']);
102
            $payment->setBatchBooking(false);
103
            $sepaFile->addPaymentInformation($payment);
104
105
            // Or if you want to use the format 'pain.001.001.03' instead
106
            $domBuilder = DomBuilderFactory::createDomBuilder($sepaFile, 'pain.001.001.03');
107
108
            if (!$domBuilder instanceof BaseDomBuilder) {
109
                throw new InvalidArgumentException('$domBuilder must be an BaseDomBuilder instance!');
110
            }
111
112
            $return[$account_info['name']] = $domBuilder->asXml();
113
        }
114
115
        return $return;
116
    }
117
118
    /**
119
     * Generates the SEPA-XML file from a single PaymentOrder.
120
     *
121
     * @param PaymentOrder $paymentOrder The payment order that should be exported
122
     * @param string|null  $account_name The name of the debitor account that should be used in export. Set to null to determine automatically
123
     * @param string|null  $iban         The IBAN of the debitor account that should be used in export. Set to null to determine automatically
124
     * @param string|null  $bic          The BIC of the debitor account that should be used in export. Set to null to determine automatically
125
     */
126
    public function exportSinglePaymentOrder(PaymentOrder $paymentOrder, ?string $account_name = null, ?string $iban = null, ?string $bic = null): string
127
    {
128
        //If null values were passed determine them from default bank account
129
        if (null === $account_name || null === $iban || null === $bic) {
130
            $bank_account = $this->getResolvedBankAccount($paymentOrder);
131
132
            $account_name = $bank_account->getExportAccountName();
133
            $iban = $bank_account->getIban();
134
            $bic = $bank_account->getBic();
135
        } elseif (!($account_name && $iban && $bic)) {
136
            throw new RuntimeException('You have to pass $account_name, $iban and $bic if you want manually select a debitor account!');
137
        }
138
139
        //Strip spaces from IBAN or we will run into problems
140
        $iban = str_replace(' ', '', $iban);
141
142
        $groupHeader = $this->getGroupHeader();
143
        $sepaFile = new CustomerCreditTransferFile($groupHeader);
144
145
        // A single payment info where all PaymentOrders are added as transactions
146
        $payment = new PaymentInformation(
147
            $paymentOrder->getIDString().' '.uniqid('', false),
148
            $iban,
149
            $bic,
150
            $account_name
151
        );
152
153
        $this->addPaymentOrderTransactions($payment, [$paymentOrder]);
154
        //This line is important
155
        $payment->setBatchBooking(false);
156
        $sepaFile->addPaymentInformation($payment);
157
158
        // Or if you want to use the format 'pain.001.001.03' instead
159
        $domBuilder = DomBuilderFactory::createDomBuilder($sepaFile, 'pain.001.001.03');
160
161
        if (!$domBuilder instanceof BaseDomBuilder) {
0 ignored issues
show
introduced by
$domBuilder is always a sub-type of Digitick\Sepa\DomBuilder\BaseDomBuilder.
Loading history...
162
            throw new InvalidArgumentException('$domBuilder must be an BaseDomBuilder instance!');
163
        }
164
165
        return $domBuilder->asXml();
166
    }
167
168
    /**
169
     * Generates a group header for a SEPA file.
170
     */
171
    protected function getGroupHeader(): GroupHeader
172
    {
173
        return new GroupHeader(
174
            static::ID_PREFIX.' '.uniqid('', false),
175
            static::PARTY_NAME
176
        );
177
    }
178
179
    /**
180
     * Generates a payment info ID for the given PaymentOrder.
181
     * Please not that the results change between calls, as the ID contains a random part to be unique.
182
     */
183
    protected function getPaymentInfoID(PaymentOrder $paymentOrder): string
184
    {
185
        return $paymentOrder->getIDString().' '.uniqid('', false);
186
    }
187
188
    /**
189
     * Add the given PaymentOrders as transactions to the SEPA PaymentInformation group.
190
     *
191
     * @param PaymentOrder[] $payment_orders
192
     */
193
    protected function addPaymentOrderTransactions(PaymentInformation $payment, array $payment_orders): void
194
    {
195
        //We only have one SEPA-Payment but it contains multiple transactions (each for one PaymentOrder)
196
        foreach ($payment_orders as $payment_order) {
197
            /** @var PaymentOrder $payment_order */
198
199
            $transfer = new CustomerCreditTransferInformation(
200
                $payment_order->getAmount(),
201
                //We need a IBAN without spaces
202
                str_replace(' ', '', $payment_order->getBankInfo()->getIban()),
203
                $payment_order->getBankInfo()
204
->getAccountOwner()
205
            );
206
            if (!empty($payment_order->getBankInfo()->getBic())) {
207
                $transfer->setBic($payment_order->getBankInfo()->getBic());
208
            }
209
            $transfer->setEndToEndIdentification($payment_order->getIDString());
210
            $transfer->setRemittanceInformation($payment_order->getBankInfo()->getReference());
211
            $payment->addTransfer($transfer);
212
        }
213
    }
214
215
    /**
216
     * This function groups the paymentOrders by bank accounts.
217
     *
218
     * @param PaymentOrder[] $payment_orders
219
     */
220
    protected function groupByBankAccounts(array $payment_orders): array
221
    {
222
        $tmp = [];
223
224
        foreach ($payment_orders as $payment_order) {
225
            $bank_account = $this->getResolvedBankAccount($payment_order);
226
227
            //That case should never really happen in reality (except for testing purposes)
228
            //But as it leads silently to wrong behavior it should throw an exception.
229
            if (null === $bank_account->getId()) {
230
                throw new RuntimeException('The associated bank account must be persisted in DB / have an ID to be groupable!');
231
            }
232
233
            //Create entry for bank account if not existing yet
234
            if (!isset($tmp[$bank_account->getId()])) {
235
                $tmp[$bank_account->getId()] = [
236
                    'iban' => $bank_account->getIbanWithoutSpaces(),
237
                    'bic' => $bank_account->getBic(),
238
                    'name' => $bank_account->getExportAccountName(),
239
                    'entries' => [],
240
                ];
241
            }
242
243
            //Add the current payment order to list
244
            $tmp[$bank_account->getId()]['entries'][] = $payment_order;
245
        }
246
247
        return $tmp;
248
    }
249
250
    /**
251
     * Get Bank account for PaymentOrder and resolve FSR-Kom bank account if needed.
252
     */
253
    protected function getResolvedBankAccount(PaymentOrder $payment_order): BankAccount
254
    {
255
        //Try to resolve FSRKom transactions if possible
256
        if ($payment_order->isFsrKomResolution()) {
257
            return $this->getFSRKomBankAccount();
258
        }
259
260
        $bank_account = $payment_order->getDepartment()
261
->getBankAccount();
262
263
        //Throw an error if auto mode is not possible (as bank account definitions are missing)
264
        if (null === $bank_account) {
265
            throw new SEPAExportAutoModeNotPossible($payment_order->getDepartment());
266
        }
267
268
        return $bank_account;
269
    }
270
271
    protected function configureOptions(OptionsResolver $resolver): void
272
    {
273
        $resolver->setRequired([
274
                                   'iban', //The IBAN of the sender
275
                                   'bic', //The BIC of the sender
276
                                   'name', //The name of the sender
277
                               ]);
278
279
        $resolver->setAllowedTypes('iban', ['string', 'null']);
280
        $resolver->setAllowedTypes('bic', ['string', 'null']);
281
        $resolver->setAllowedTypes('name', ['string', 'null']);
282
283
        /* Two different modes, in "manual" all transactions are put in a single payment from the given account data,
284
           the accounts for the payment order departments are used automatically and are put in (if needed) multiple
285
           payments from different accounts */
286
        $resolver->setDefault('mode', 'manual');
287
        $resolver->setAllowedValues('mode', ['auto', 'manual', 'auto_single']);
288
289
        $resolver->setNormalizer('iban', function (Options $options, $value) {
290
            if (null === $value) {
291
                return null;
292
            }
293
            //Return spaces from IBAN
294
            return str_replace(' ', '', $value);
295
        });
296
    }
297
298
    /**
299
     * Returns the bank account associated with FSR-Kom (this is configured by FSR_KOM_ACCOUNT_ID env).
300
     */
301
    public function getFSRKomBankAccount(): BankAccount
302
    {
303
        return $this->entityManager->find(BankAccount::class, $this->fsr_kom_bank_account_id);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->entityMana...sr_kom_bank_account_id) could return the type null which is incompatible with the type-hinted return App\Entity\BankAccount. Consider adding an additional type-check to rule them out.
Loading history...
304
    }
305
}
306