lan_kiosk/app/Http/Controllers/SwishController.php
Anna-Sara Sélea df9f1cf17e Swish feature
2026-08-22 16:13:42 +02:00

379 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Deposit;
use App\Models\Transaction;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class SwishController extends Controller
{
/** Text the payer sees in the Swish app. Max 50 characters. */
private const PAYMENT_MESSAGE = 'vBytes LAN';
public function lookup(Request $request): JsonResponse
{
$request->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, '|');
}
}