validate(['lan_id' => 'required|integer']); $customer = Customer::where('lan_id', $request->lan_id)->first(); if (!$customer) { return response()->json(['message' => 'No customer found'], 404); } $memberName = $customer->is_in_group ? $customer->name : null; $account = $this->accountFor($customer); if (!$account) { return response()->json(['message' => 'No group found'], 404); } // This endpoint has to be reachable without logging in, so it answers with // as little as the payer needs to recognise themselves. A group's name is // a label the group chose and is left as it is. $accountIsGroup = $account->customer_group_id !== null; return response()->json([ 'customer_id' => $account->id, 'name' => $accountIsGroup ? $account->name : $this->maskName($account->name), 'member_name' => $memberName === null ? null : $this->maskName($memberName), ]); } /** * "Erik Olsson" becomes "Erik O." — enough for the payer to see that they * typed the right LAN id, not enough to be worth harvesting. */ private function maskName(string $name): string { $parts = preg_split('/\s+/', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: []; if ($parts === []) { return ''; } $masked = [array_shift($parts)]; foreach ($parts as $part) { $masked[] = mb_strtoupper(mb_substr($part, 0, 1)) . '.'; } return implode(' ', $masked); } /** * The account a payment lands on. For a group member that is the group's own * customer row, which is where the balance lives. */ private function accountFor(Customer $customer): ?Customer { if (!$customer->is_in_group) { return $customer; } return Customer::where('customer_group_id', $customer->customer_group_id) ->where('is_in_group', 0) ->first(); } public function initiate(Request $request): JsonResponse { $request->validate([ 'customer_id' => 'required|integer|exists:customers,id', 'amount' => 'required|integer|min:1', 'give_leftover' => 'required|boolean', ]); // Allowed characters in payeePaymentReference are a-z A-Z 0-9 and -, max 35 $ref = 'LAN-' . strtoupper(Str::random(8)); $transaction = Transaction::create([ 'payment_reference' => $ref, 'customer_id' => $request->customer_id, 'expected_amount' => $request->amount, 'status' => 'pending', 'give_leftover' => $request->give_leftover, 'source' => 'kiosk', 'instruction_uuid' => strtoupper(str_replace('-', '', (string) Str::uuid())), // 32-36 characters, validated by Swish against ^[0-9a-zA-Z-]{32,36}$ 'callback_identifier' => (string) Str::uuid(), ]); try { $token = $this->createPaymentRequest($transaction); } catch (Throwable $e) { $transaction->status = 'failed'; $transaction->save(); Log::error('Swish: could not create payment request', [ 'ref' => $ref, 'error' => $e->getMessage(), // Swish answers validation errors with a body of error codes — log it, // it is the difference between a two-minute and a two-hour debug session 'response' => $e instanceof RequestException && $e->hasResponse() ? (string) $e->getResponse()->getBody() : null, ]); return response()->json(['message' => 'Kunde inte starta betalningen hos Swish.'], 502); } // The token is what the Swish app opens. callbackurl only brings the payer back // here afterwards — the payment result is the callback Swish POSTs to our server. $swishUrl = 'swish://paymentrequest?token=' . $token . '&callbackurl=' . urlencode(url('/swish')); return response()->json([ 'swish_url' => $swishUrl, 'payment_reference' => $ref, ]); } /** * Register the payment with Swish and return the token the app switches on. * * The client certificate is what tells Swish which merchant is calling, and * is therefore also what makes the callback possible. */ private function createPaymentRequest(Transaction $transaction): string { $baseUrl = trim((string) config('app.swish_api_url')); if ($baseUrl === '') { throw new RuntimeException('SWISH_API_URL is not set'); } $options = [ 'timeout' => 15, 'connect_timeout' => 10, 'json' => [ 'payeeAlias' => config('app.swish_payee_alias'), 'currency' => 'SEK', 'amount' => number_format((float) $transaction->expected_amount, 2, '.', ''), 'callbackUrl' => rtrim((string) config('app.swish_callback_url'), '/') . '/kiosk', 'payeePaymentReference' => $transaction->payment_reference, 'message' => self::PAYMENT_MESSAGE, 'callbackIdentifier' => $transaction->callback_identifier, ], ]; if ($cert = config('app.swish_clientcert_path')) { $options['cert'] = $cert; } else { Log::warning('Swish: no client certificate configured, the API will reject the call'); } if ($ca = config('app.swish_ca_path')) { $options['verify'] = $ca; } // Resolved from the container so tests can swap in a fake Swish $response = app(Client::class)->request( 'PUT', rtrim($baseUrl, '/') . '/swish-cpcapi/api/v2/paymentrequests/' . $transaction->instruction_uuid, $options ); // m-commerce hands back the app-switch token in a response header $token = $response->getHeaderLine('PaymentRequestToken'); if ($token === '') { throw new RuntimeException('Swish response contained no PaymentRequestToken header'); } return $token; } public function callback(Request $request): Response { Log::info('Swish callback received', [ 'content_type' => $request->header('Content-Type'), 'body' => $request->getContent(), 'parsed' => $request->all(), ]); $ref = $this->paymentReference($request); $transaction = Transaction::where('payment_reference', $ref)->first(); if (!$transaction) { Log::warning('Swish: no transaction found for reference', ['ref' => $ref]); return response()->noContent(); } // Anyone can reach this endpoint, so the callback has to prove it came from // Swish. callbackIdentifier is sent with the payment request and returned as // a header; it is never shared outside that channel. if (!$this->identifierMatches($request, $transaction)) { Log::warning('Swish: callbackIdentifier mismatch, callback rejected', ['ref' => $ref]); return response()->noContent(403); } $status = (string) $request->input('status'); if ($status !== 'PAID') { // Record the outcome so a failed payment is not left looking pending if ($transaction->status !== 'PAID') { $transaction->status = $status !== '' ? $status : 'unknown'; $transaction->save(); } Log::info('Swish: payment not completed', ['ref' => $ref, 'status' => $status]); return response()->noContent(); } $paid = (float) $request->input('amount'); $expected = $transaction->expected_amount; // The amount is locked in the payment request, so it cannot legitimately // differ here. If it does, this is a bug or a callback we should not trust. if ($expected === null || abs($paid - (float) $expected) > 0.001) { $transaction->status = 'amount_mismatch'; $transaction->save(); Log::error('Swish: paid amount does not match the payment request', [ 'ref' => $ref, 'expected' => $expected, 'paid' => $paid, ]); return response()->noContent(); } // Credit the amount we asked for, never a number taken from the callback $amount = (int) $expected; $credited = $this->credit($transaction, $amount); if (!$credited) { Log::info('Swish: callback did not result in a deposit', ['ref' => $ref]); return response()->noContent(); } Log::info('Swish: payment processed', [ 'ref' => $ref, 'customer_id' => $transaction->customer_id, 'amount' => $amount, 'give_leftover' => $transaction->give_leftover, ]); return response()->noContent(); } /** * Move the money onto the customer's balance. * * Swish resends a callback until it is answered, and two of them can arrive at * once. Everything runs in one database transaction with the rows locked, so a * repeat finds the payment already settled instead of crediting it twice. */ private function credit(Transaction $transaction, int $amount): bool { return DB::transaction(function () use ($transaction, $amount) { $locked = Transaction::whereKey($transaction->id)->lockForUpdate()->first(); if ($locked->status === 'PAID') { Log::info('Swish: duplicate callback ignored', ['ref' => $locked->payment_reference]); return false; } $customer = Customer::find($locked->customer_id); if (!$customer) { Log::warning('Swish: no customer found for transaction', ['transaction_id' => $locked->id]); return false; } // A group member pays into the group's balance, everyone else into their // own. lookup() normally resolves this before the transaction is created, // but a transaction made some other way may still point at the member. $account = $this->accountFor($customer); if (!$account) { Log::warning('Swish: no group account found for customer', ['customer_id' => $customer->id]); return false; } // Lock the row we are about to read-modify-write, so two payments into the // same account cannot overwrite each other's balance $account = Customer::whereKey($account->id)->lockForUpdate()->first(); $account->deposit += $amount; $account->amount_left += $amount; $account->give_leftover = $locked->give_leftover; $account->save(); if ($account->id !== $customer->id) { $customer->deposit = 0; $customer->save(); } Deposit::create([ 'customer_id' => $customer->id, 'amount' => $amount, ]); $locked->status = 'PAID'; $locked->save(); return true; }); } /** * A transaction without an identifier was never registered with Swish, so a * callback for it cannot be verified and is not trusted. */ private function identifierMatches(Request $request, Transaction $transaction): bool { if (!$transaction->callback_identifier) { Log::warning('Swish: transaction has no callbackIdentifier to verify against', [ 'ref' => $transaction->payment_reference, ]); return false; } return hash_equals($transaction->callback_identifier, (string) $request->header('callbackIdentifier')); } /** * Status of a payment, polled by the payment page while the payer is in the * Swish app. The reference is random and only known to that payer. */ public function status(Request $request): JsonResponse { $request->validate(['reference' => 'required|string']); $transaction = Transaction::where('payment_reference', $request->reference)->first(); if (!$transaction) { return response()->json(['message' => 'Unknown reference'], 404); } return response()->json([ 'status' => $transaction->status, 'amount' => $transaction->expected_amount, ]); } /** * Our reference travels in payeePaymentReference. Older payments carried it in * message, sometimes behind a "kiosk|" prefix, so accept those too. */ private function paymentReference(Request $request): string { $ref = (string) ($request->input('payeePaymentReference') ?: $request->input('message')); return Str::afterLast($ref, '|'); } }