mirror of
https://github.com/anna-sara/lan_kiosk
synced 2026-09-03 12:25:02 +02:00
379 lines
14 KiB
PHP
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, '|');
|
|
}
|
|
}
|