Swish feature

This commit is contained in:
Anna-Sara Sélea 2026-08-22 16:13:42 +02:00
parent b42be16c46
commit df9f1cf17e
34 changed files with 2733 additions and 240 deletions

View file

@ -63,3 +63,42 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# ---------------------------------------------------------------
# Swish
# ---------------------------------------------------------------
# The Swish merchant number (Swish för företag) that receives payments.
SWISH_PAYEE_ALIAS=
# Where Swish should send payment callbacks.
# This must be a publicly reachable HTTPS URL pointing at the swish-receiver (Python app).
# The /kiosk path suffix tells the Python app to route this callback to the kiosk app.
# The Python app looks up KIOSK_CALLBACK_URL from its .env and forwards the request there.
#
# Local dev (Python app running on localhost):
# SWISH_CALLBACK_URL=http://localhost:8000/webhook/swish
#
# Production (Python app's public URL):
# SWISH_CALLBACK_URL=https://swish.vbytes.se/webhook/swish
SWISH_CALLBACK_URL=
# The Swish Handel API. Payments are registered here before the app is opened —
# without this call Swish never learns about the payment and no callback is sent.
#
# Test "MSS" (open test environment, no agreement needed):
# SWISH_API_URL=https://mss.cpc.getswish.net
#
# Production:
# SWISH_API_URL=https://cpc.getswish.net
SWISH_API_URL=
# Client certificate for the API call (mutual TLS). This is what identifies us as
# the merchant. Convert the .p12 from the bank (or Swish's test certificate) to a
# combined PEM holding both certificate and private key:
# openssl pkcs12 -in cert.p12 -out swish.pem -nodes
SWISH_CLIENTCERT_PATH=
# Swish's own root CA, used to verify their server certificate.
# Corresponds to --cacert in the curl examples in the Swish documentation.
SWISH_CA_PATH=

View file

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Models\Transaction;
use Illuminate\Console\Command;
class PruneStaleTransactions extends Command
{
protected $signature = 'app:prune-stale-transactions';
protected $description = 'Mark pending transactions older than 2 hours as expired';
public function handle()
{
// Marked rather than deleted: Swish retries a callback for longer than this,
// and a payment whose row is gone can never be booked. An expired row is
// still matched by the callback, and is there to reconcile against.
$expired = Transaction::where('status', 'pending')
->where('created_at', '<', now()->subHours(2))
->update(['status' => 'expired']);
$this->info("Marked {$expired} stale transaction(s) as expired.");
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\Tableversion;
use GuzzleHttp\Client;
use Illuminate\Console\Command;
class SyncCustomers extends Command
{
protected $signature = 'app:sync-customers';
protected $description = 'Sync participants and volunteers from the LAN API';
public function handle(): void
{
$client = new Client();
$versions = $this->fetchVersions($client);
if (!$versions) {
return;
}
$participantVersion = $this->ensureVersion('participants', $versions['participants']);
$volunteerVersion = $this->ensureVersion('volunteers', $versions['volunteers']);
if (
$participantVersion->version >= $versions['participants'] &&
$volunteerVersion->version >= $versions['volunteers']
) {
$this->info('Already up to date.');
return;
}
$data = $this->fetchData($client);
if (!$data) {
return;
}
foreach ($data['participants'] as $participant) {
Customer::updateOrCreate(
['lan_id' => $participant['lan_id']],
[
'name' => $participant['first_name'] . ' ' . $participant['surname'],
'guardian_name' => $participant['guardian_name'],
]
);
}
foreach ($data['volunteers'] as $volunteer) {
Customer::updateOrCreate(
['lan_id' => $volunteer['lan_id']],
[
'name' => $volunteer['first_name'] . ' ' . $volunteer['surname'],
'guardian_name' => $volunteer['first_name'] . ' ' . $volunteer['surname'],
]
);
}
if ($participantVersion->version < $versions['participants']) {
Tableversion::create(['table' => 'participants', 'version' => $participantVersion->version + 1]);
$this->info('Participants synced.');
}
if ($volunteerVersion->version < $versions['volunteers']) {
Tableversion::create(['table' => 'volunteers', 'version' => $volunteerVersion->version + 1]);
$this->info('Volunteers synced.');
}
}
private function ensureVersion(string $table, int $latestVersion): Tableversion
{
$version = Tableversion::where('table', $table)->latest()->first();
if (!$version) {
$version = Tableversion::create(['table' => $table, 'version' => $latestVersion - 1]);
}
return $version;
}
private function fetchVersions(Client $client): ?array
{
try {
$response = $client->get(config('app.apilan_url') . 'version', $this->requestOptions());
return json_decode((string) $response->getBody(), true);
} catch (\Exception $e) {
$this->error('Failed to fetch versions: ' . $e->getMessage());
return null;
}
}
private function fetchData(Client $client): ?array
{
try {
$response = $client->get(config('app.apilan_url') . 'data', $this->requestOptions());
return json_decode((string) $response->getBody(), true);
} catch (\Exception $e) {
$this->error('Failed to fetch data: ' . $e->getMessage());
return null;
}
}
private function requestOptions(): array
{
return [
'headers' => [
'X-Api-Key' => config('app.apilan_key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'cert' => config('app.apilan_clientcert_path'),
];
}
}

View file

@ -7,6 +7,12 @@ use App\Models\CustomerGroup;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Purchase; use App\Models\Purchase;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Storage;
use App\Models\Tableversion;
class CustomerController extends Controller class CustomerController extends Controller
{ {
@ -34,18 +40,120 @@ class CustomerController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $request->validate([
'lan_id' => ['required'],
'name' => 'required', 'name' => 'required',
'guardian_name' => 'required', 'guardian_name' => 'required',
'give_leftover' => 'nullable', 'give_leftover' => 'nullable',
]); ]);
Customer::create([ $customer = Customer::create([
'lan_id' => $request->lan_id,
'name' => $request->name, 'name' => $request->name,
'guardian_name' => $request->guardian_name, 'guardian_name' => $request->guardian_name,
'give_leftover' => $request->give_leftover, 'give_leftover' => $request->give_leftover,
]); ]);
return redirect(route('thankyou', absolute: false)); return redirect('customer/' . $customer->id);
}
/**
* Load customers
*/
public function load()
{
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
$client = new Client();
$responseVersions= $client->request(
'GET',
config('app.apilan_url') . "version",
[
'headers'=> [
'X-Api-Key' => config('app.apilan_key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json'
],
'cert' => config('app.apilan_clientcert_path')
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
],
);
$versions = json_decode((string) $responseVersions->getBody(), true);
if ($latestVersionParticipant === null ) {
Tableversion::create([
'table' => 'participants',
'version' => $versions['participants'] - 1,
]);
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
}
if ( $latestVersionVolunteer === null ) {
Tableversion::create([
'table' => 'volunteers',
'version' => $versions['volunteers'] - 1,
]);
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
}
if($latestVersionParticipant->version < $versions['participants'] || $latestVersionVolunteer->version < $versions['volunteers'] ) {
$response = $client->request(
'GET',
config('app.apilan_url') . "data",
[
'headers'=> [
'X-Api-Key' => config('app.apilan_key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json'
],
'cert' => config('app.apilan_clientcert_path')
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
],
);
$response_data = json_decode((string) $response->getBody(), true);
foreach ($response_data['participants'] as $participant) {
Customer::updateOrCreate(
['lan_id' => $participant['lan_id']],
[
'lan_id' => $participant['lan_id'],
'name' => $participant['first_name'] . " " . $participant['surname'],
'guardian_name' => $participant['guardian_name'],
]
);
}
foreach ($response_data['volunteers'] as $volunteer) {
Customer::updateOrCreate(
['lan_id' => $volunteer['lan_id']],
[
'lan_id' => $volunteer['lan_id'],
'name' => $volunteer['first_name'] . " " . $volunteer['surname'],
'guardian_name' => $volunteer['first_name'] . " " . $volunteer['surname']
]
);
}
if($latestVersionParticipant->version < $versions['participants']) {
Tableversion::create([
'table' => 'participants',
'version' =>$latestVersionParticipant->version + 1,
]);
}
if($latestVersionVolunteer->version < $versions['volunteers']) {
Tableversion::create([
'table' => 'volunteers',
'version' =>$latestVersionVolunteer->version + 1,
]);
}
}
} }
/** /**
@ -56,8 +164,10 @@ class CustomerController extends Controller
$customer = Customer::with('purchases')->with('deposits')->findOrFail($id); $customer = Customer::with('purchases')->with('deposits')->findOrFail($id);
$groupmembers = Customer::where('is_in_group', 1)->where('customer_group_id', $customer->customer_group_id)->get(); $groupmembers = Customer::where('is_in_group', 1)->where('customer_group_id', $customer->customer_group_id)->get();
return Inertia::render('Customer', [
return Inertia::render('Customer', ['customer' => $customer, 'groupmembers' => $groupmembers]); 'customer' => $customer,
'groupmembers' => $groupmembers,
]);
} }
/** /**

View file

@ -59,13 +59,7 @@ class CustomerGroupController extends Controller
$groupAmount= 0; $groupAmount= 0;
foreach ($customers as $customerItem) { foreach ($customers as $customerItem) {
$customer = Customer::findOrFail($customerItem); $groupAmount += Customer::findOrFail($customerItem)->joinGroup($customerGroup->id);
$customer->customer_group_id = $customerGroup->id;
$customer->is_in_group = 1;
$groupAmount += $customer->amount_left;
$customer->deposit = 0;
$customer->amount_left = 0;
$customer->save();
} }
Deposit::create([ Deposit::create([
@ -110,15 +104,27 @@ class CustomerGroupController extends Controller
$customers = $request->customers; $customers = $request->customers;
$groupCustomer = Customer::where('customer_group_id', $id)->where('is_in_group', 0)->first(); $groupCustomer = Customer::where('customer_group_id', $id)->where('is_in_group', 0)->first();
if (!$groupCustomer) {
return response()->json([
'success' => false, 'message' => 'Customer group not found'
], 404);
}
$movedAmount = 0;
foreach ($customers as $customerItem) { foreach ($customers as $customerItem) {
$customer = Customer::findOrFail($customerItem); $movedAmount += Customer::findOrFail($customerItem)->joinGroup($id);
$groupCustomer->deposit += $customer->deposit; }
$groupCustomer->amount_left += $customer->deposit;
$groupCustomer->deposit += $movedAmount;
$groupCustomer->amount_left += $movedAmount;
$groupCustomer->save(); $groupCustomer->save();
$customer->customer_group_id = $id;
$customer->is_in_group = 1; if ($movedAmount > 0) {
$customer->deposit = 0; Deposit::create([
$customer->save(); 'customer_id' => $groupCustomer->id,
'amount' => $movedAmount,
]);
} }
return response()->json([ return response()->json([
@ -132,8 +138,25 @@ class CustomerGroupController extends Controller
public function destroy($id) public function destroy($id)
{ {
$group = CustomerGroup::findOrFail( $id ); $group = CustomerGroup::findOrFail( $id );
$customerGroup = Customer::where('customer_group_id', $group->id);
$customerGroup->delete(); $groupCustomer = Customer::where('customer_group_id', $group->id)
->where('is_in_group', 0)
->first();
// The balance lives on the group's own row and would disappear with it
if ($groupCustomer && $groupCustomer->amount_left > 0) {
return response()->json([
'success' => false,
'message' => 'Gruppen har ' . $groupCustomer->amount_left . ' kr kvar på saldot. Betala ut eller använd upp det innan gruppen tas bort.'
], 422);
}
// The members are real participants — they leave the group, they are not deleted
Customer::where('customer_group_id', $group->id)
->where('is_in_group', 1)
->update(['customer_group_id' => null, 'is_in_group' => 0]);
$groupCustomer?->delete();
$group->delete(); $group->delete();
return response()->json([ return response()->json([

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Deposit; use App\Models\Deposit;
use App\Models\Customer; use App\Models\Customer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DepositController extends Controller class DepositController extends Controller
{ {
@ -42,45 +43,55 @@ class DepositController extends Controller
$customer = Customer::where('lan_id',$request->customer_id)->first(); $customer = Customer::where('lan_id',$request->customer_id)->first();
} }
if (!$customer) {
return response()->json([
'success' => false, 'message' => 'Hittade ingen deltagare'
], 404);
}
// A group member pays into the group's balance
if ($customer->is_in_group) {
$account = Customer::where('customer_group_id', $customer->customer_group_id)->where('is_in_group', 0)->first();
} else {
$account = $customer;
}
// Work out where the money goes before writing anything. Creating the
// deposit first would leave a receipt behind for money never credited.
if (!$account) {
return response()->json([
'success' => false, 'message' => 'Hittade inget konto att sätta in på'
], 404);
}
$amount = (int) $request->deposit;
DB::transaction(function () use ($customer, $account, $amount, $request) {
$account->deposit += $amount;
$account->amount_left += $amount;
$account->give_leftover = $request->give_leftover;
$account->save();
// The balance lives on the group, so the member keeps none of it
if ($account->id !== $customer->id) {
$customer->deposit = 0;
$customer->save();
}
Deposit::create([ Deposit::create([
'customer_id' => $customer->id, 'customer_id' => $customer->id,
'amount' => $request->deposit, 'amount' => $amount,
]); ]);
});
if ($customer->is_in_group ) {
$groupCustomer = Customer::where('customer_group_id', $customer->customer_group_id)->where('is_in_group', 0)->first();
$groupCustomer->deposit += $request->deposit;
$groupCustomer->amount_left += $request->deposit;
$groupCustomer->give_leftover = $request->give_leftover;
$groupCustomer->save();
$customer->deposit = 0;
$customer->save();
if ($request->manual_deposit === 1) { if ($request->manual_deposit === 1) {
return redirect('customer/' . $request->customer_id); return redirect('customer/' . $request->customer_id);
} }
return response()->json([ return response()->json([
'success' => true, 'message' => 'Deposit added successfully' 'success' => true, 'message' => 'Deposit added successfully'
]); ]);
} else {
$customer->deposit = $customer->deposit + $request->deposit;
$customer->amount_left = $customer->amount_left + $request->deposit;
$customer->give_leftover = $request->give_leftover;
$customer->save();
if ($request->manual_deposit === 1) {
return redirect('customer/' . $request->customer_id);
}
return response()->json([
'success' => true, 'message' => 'Deposit added successfully'
]);
}
} }
/** /**
@ -113,10 +124,23 @@ class DepositController extends Controller
public function destroy($id) public function destroy($id)
{ {
$deposit = Deposit::findOrFail( $id ); $deposit = Deposit::findOrFail( $id );
$customer = Customer::where('id', $deposit->customer_id); $customer = Customer::findOrFail( $deposit->customer_id );
$customer->amount_left = $customer->amount_left + $deposit->amount; $amount = (int) $deposit->amount;
$customer->amount_used = $customer->amount_used - $deposit->amount;
$customer->save(); // The deposit was added to the group's balance for a member, so that is
// where it has to be taken from again — same resolution as in store()
if ($customer->is_in_group) {
$account = Customer::where('customer_group_id', $customer->customer_group_id)
->where('is_in_group', 0)
->first();
} else {
$account = $customer;
}
$account->deposit = $account->deposit - $amount;
$account->amount_left = $account->amount_left - $amount;
$account->save();
$deposit->delete(); $deposit->delete();
return response()->json([ return response()->json([

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Purchase; use App\Models\Purchase;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Customer; use App\Models\Customer;
use Illuminate\Support\Facades\DB;
class PurchaseController extends Controller class PurchaseController extends Controller
{ {
@ -35,15 +36,21 @@ class PurchaseController extends Controller
'amount' => 'nullable', 'amount' => 'nullable',
]); ]);
Purchase::create([ // Look the customer up before writing anything — creating the purchase
'customer_id' => $data['customer_id'], // first would leave a line item behind that no balance ever paid for
'amount' => $data['amount'],
]);
$customer = Customer::findOrFail( $data['customer_id'] ); $customer = Customer::findOrFail( $data['customer_id'] );
$customer->amount_left = $customer->amount_left - $data['amount']; $amount = (int) $data['amount'];
DB::transaction(function () use ($customer, $amount) {
$customer->amount_left = $customer->amount_left - $amount;
$customer->save(); $customer->save();
Purchase::create([
'customer_id' => $customer->id,
'amount' => $amount,
]);
});
return redirect('customer/' . $customer->id); return redirect('customer/' . $customer->id);
} }
@ -77,10 +84,13 @@ class PurchaseController extends Controller
public function destroy($id) public function destroy($id)
{ {
$purchase = Purchase::findOrFail( $id ); $purchase = Purchase::findOrFail( $id );
$customer = Customer::where('id', $purchase->customer_id); $customer = Customer::findOrFail( $purchase->customer_id );
$customer->amount_left = $customer->amount_left + $purchase->amount;
$customer->amount_used = $customer->amount_used - $purchase->amount; // store() takes the amount off this customer's balance, so removing the
// purchase puts it back on the same balance
$customer->amount_left = $customer->amount_left + (int) $purchase->amount;
$customer->save(); $customer->save();
$purchase->delete(); $purchase->delete();
return response()->json([ return response()->json([

View file

@ -0,0 +1,379 @@
<?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, '|');
}
}

View file

@ -17,8 +17,7 @@ class Customer extends Model
'lan_id', 'lan_id',
'name', 'name',
'guardian_name', 'guardian_name',
'amount left', 'amount_left',
'amount used',
'deposit', 'deposit',
'give_leftover', 'give_leftover',
'comment', 'comment',
@ -26,6 +25,26 @@ class Customer extends Model
'is_in_group' 'is_in_group'
]; ];
/**
* Make this customer a member of a group and hand over the balance they have
* left to spend. Returns the amount that was moved, for the caller to add to
* the group's own row.
*
* Membership and money move together a member keeps no balance of their own.
*/
public function joinGroup(int $groupId): int
{
$moved = (int) $this->amount_left;
$this->customer_group_id = $groupId;
$this->is_in_group = 1;
$this->deposit = 0;
$this->amount_left = 0;
$this->save();
return $moved;
}
/** /**
* Get the purchases for the customer. * Get the purchases for the customer.
*/ */

View file

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Transaction extends Model
{
protected $fillable = [
'payment_reference',
'customer_id',
'expected_amount',
'status',
'give_leftover',
'source',
'instruction_uuid',
'callback_identifier',
];
/** Shared secret with Swish — never expose it in a response */
protected $hidden = [
'callback_identifier',
];
}

View file

@ -126,6 +126,11 @@ return [
'apikey_deposit' => env('API_KEY_DEPOSIT'), 'apikey_deposit' => env('API_KEY_DEPOSIT'),
'apilan_key' => env('API_LAN_KEY'), 'apilan_key' => env('API_LAN_KEY'),
'apilan_url' => env('API_LAN_URL'), 'apilan_url' => env('API_LAN_URL'),
'apilan_clientcert_path' => env('API_LAN_CLIENTCERT_PATH') 'apilan_clientcert_path' => env('API_LAN_CLIENTCERT_PATH'),
'swish_payee_alias' => env('SWISH_PAYEE_ALIAS'),
'swish_callback_url' => env('SWISH_CALLBACK_URL'),
'swish_api_url' => env('SWISH_API_URL'),
'swish_clientcert_path' => env('SWISH_CLIENTCERT_PATH'),
'swish_ca_path' => env('SWISH_CA_PATH'),
]; ];

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->id();
// Sent to Swish as payeePaymentReference and returned in the callback
$table->string('payment_reference')->unique();
$table->foreignId('customer_id');
$table->integer('expected_amount')->nullable();
$table->string('status')->default('pending');
$table->boolean('give_leftover')->default(false);
$table->string('source')->default('admin');
// The UUID the payment request is created under in the Swish API,
// needed to ask Swish about the payment afterwards
$table->string('instruction_uuid')->nullable();
// Sent with the payment request and returned unchanged as an HTTP header
// in the callback. Never leaves the channel between us and Swish, so it
// is what proves a callback really came from Swish.
$table->string('callback_identifier')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('transactions');
}
};

129
dev/fake-swish.php Normal file
View file

@ -0,0 +1,129 @@
<?php
/**
* A stand-in for the Swish Handel API, for trying the payment flow locally
* before there is an agreement and a certificate.
*
* It answers the payment request the way Swish does with a
* PaymentRequestToken header and then calls the callback URL from the request
* a few seconds later, carrying the callbackIdentifier back as a header just
* like the real one. Everything downstream is real: the receiver app, the
* callback verification, the booking.
*
* The only thing it cannot do is open the Swish app. The token is made up, so
* the app would reject it on a phone the page simply stays in the waiting
* state until the fake callback lands, which is exactly what we want to see.
*
* Start it inside the kiosk container:
*
* docker exec -d lan_kiosk-api-1 php -S 127.0.0.1:9099 /var/www/html/dev/fake-swish.php
*
* and point the app at it in .env:
*
* SWISH_API_URL=http://127.0.0.1:9099
*
* Amounts that behave differently, so every screen can be reached:
*
* 13 kr DECLINED, the payer aborted in the app
* 1 kr no callback at all, the page keeps waiting until it times out
* other PAID after DELAY_SECONDS
*
* Delete this directory once Swish Handel is in place.
*/
const DELAY_SECONDS = 4;
// Second mode: started in the background by the request below to deliver the
// callback after the payment request has already been answered.
if (PHP_SAPI === 'cli') {
$payload = json_decode(base64_decode($argv[1] ?? ''), true);
if (!$payload) {
exit(1);
}
sleep(DELAY_SECONDS);
$body = json_encode($payload['body']);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n"
. 'callbackIdentifier: ' . $payload['identifier'] . "\r\n",
'content' => $body,
'timeout' => 15,
'ignore_errors' => true,
],
]);
$result = @file_get_contents($payload['url'], false, $context);
file_put_contents('/tmp/fake-swish.log', sprintf(
"[%s] callback %s -> %s\n",
date('H:i:s'),
$payload['url'],
$result === false ? 'FAILED' : 'sent'
), FILE_APPEND);
exit(0);
}
// First mode: the payment request itself
$request = json_decode(file_get_contents('php://input'), true) ?: [];
$amount = (float) ($request['amount'] ?? 0);
file_put_contents('/tmp/fake-swish.log', sprintf(
"[%s] %s %s %s\n",
date('H:i:s'),
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
json_encode($request)
), FILE_APPEND);
// Swish rejects a payment request without a payee, and so do we
if (empty($request['payeeAlias'])) {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode([['errorCode' => 'PA02', 'errorMessage' => 'Payee alias is missing']]);
return;
}
$instructionUuid = basename($_SERVER['REQUEST_URI']);
header('PaymentRequestToken: FAKE' . substr($instructionUuid, 0, 12));
header('Location: http://127.0.0.1:9099/swish-cpcapi/api/v1/paymentrequests/' . $instructionUuid);
http_response_code(201);
// 1 kr means "the payer never finishes", so no callback is ever sent
if ((int) $amount === 1) {
return;
}
$callback = [
// Already ends in /kiosk — the app puts it there so the receiver knows where to forward
'url' => $request['callbackUrl'],
'identifier' => $request['callbackIdentifier'] ?? '',
'body' => [
'id' => $instructionUuid,
'payeePaymentReference' => $request['payeePaymentReference'] ?? '',
'paymentReference' => strtoupper(bin2hex(random_bytes(16))),
'payerAlias' => '46701234567',
'payeeAlias' => $request['payeeAlias'],
'amount' => $amount,
'currency' => 'SEK',
'message' => $request['message'] ?? '',
'status' => (int) $amount === 13 ? 'DECLINED' : 'PAID',
'errorCode' => (int) $amount === 13 ? 'BANKIDCL' : null,
'errorMessage' => '',
'dateCreated' => date('c'),
'datePaid' => (int) $amount === 13 ? null : date('c'),
],
];
// Answer first, deliver the callback afterwards — the same order as the real thing
exec(sprintf(
'php %s %s > /dev/null 2>&1 &',
escapeshellarg(__FILE__),
escapeshellarg(base64_encode(json_encode($callback)))
));

View file

@ -22,6 +22,7 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>

View file

@ -41,7 +41,16 @@ export default function Authenticated({
Grupper Grupper
</NavLink> </NavLink>
</div> </div>
<div className="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink
href="/settings"
active={route().current('settings')}
>
Inställningar
</NavLink>
</div> </div>
</div>
<div className="hidden sm:ms-6 sm:flex sm:items-center"> <div className="hidden sm:ms-6 sm:flex sm:items-center">
<div className="relative ms-3"> <div className="relative ms-3">

View file

@ -47,7 +47,7 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
id: customer.id, id: customer.id,
comment: "", comment: "",
manual_deposit: 0, manual_deposit: 0,
give_leftover: 0 give_leftover: customer.give_leftover
}); });
@ -84,11 +84,9 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
.catch(error => {console.log(error)}) .catch(error => {console.log(error)})
} }
console.log( import.meta.env.API_KEY_DEPOSIT)
return ( return (
<AuthenticatedLayout> <AuthenticatedLayout>
<Head title="Deltagare" /> <Head title="Deltagare | vBytes LAN Kiosk" />
<section className='section'> <section className='section'>
<div className="container is-max-desktop"> <div className="container is-max-desktop">
@ -174,10 +172,9 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
</svg> </svg>
</div> </div>
</details> </details>
{/*{groupmembers.length < 1 &&*/}
<details className="box"> <details className="box">
<summary className='title is-4 my-3'> <summary className='title is-4 my-3'>
<span>Inbetalning Swish/kontant</span> <span>Inbetalning Kontant</span>
<div className="summary-chevron-up"> <div className="summary-chevron-up">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="feather feather-chevron-down"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="feather feather-chevron-down">
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
@ -202,7 +199,8 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
<label className="radio mr-3 mt-3"> <label className="radio mr-3 mt-3">
<input <input
type="radio" type="radio"
checked={customer.give_leftover === 1} className="accent-blue-600"
checked={data.give_leftover === 1}
onChange={() => setData('give_leftover', 1)} onChange={() => setData('give_leftover', 1)}
/> />
Ja Ja
@ -210,7 +208,8 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
<label className="radio"> <label className="radio">
<input <input
type="radio" type="radio"
checked={customer.give_leftover === 0} className="accent-blue-600"
checked={data.give_leftover === 0}
onChange={() => setData('give_leftover', 0)} onChange={() => setData('give_leftover', 0)}
/> />
Nej Nej
@ -230,7 +229,6 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
</svg> </svg>
</div> </div>
</details> </details>
{/*}*/}
<details className="box"> <details className="box">

View file

@ -202,7 +202,7 @@ export default function CustomerGroups({groups, customers} :( CustomerGroupProps
return ( return (
<AuthenticatedLayout> <AuthenticatedLayout>
<Head title="Dashboard" /> <Head title="Grupper | vBytes LAN Kiosk" />
{ addCustomerToGroupModal && { addCustomerToGroupModal &&
<div className='add-customer-to-group-modal'> <div className='add-customer-to-group-modal'>
<div className='add-customer-to-group-modal-content'> <div className='add-customer-to-group-modal-content'>

View file

@ -53,7 +53,7 @@ export default function Dashboard({ customers }: CustomerProps) {
return ( return (
<AuthenticatedLayout> <AuthenticatedLayout>
<Head title="Dashboard" /> <Head title="Dashboard | vBytes LAN Kiosk" />
<section className='section'> <section className='section'>
<div className="container is-max-desktop"> <div className="container is-max-desktop">

View file

@ -7,6 +7,7 @@ import { FormEventHandler } from 'react';
export default function Form() { export default function Form() {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
lan_id: '',
name: '', name: '',
guardian_name: '', guardian_name: '',
give_leftover: false as boolean, give_leftover: false as boolean,
@ -21,7 +22,7 @@ export default function Form() {
return ( return (
<div> <div>
<Head title="Form" /> <Head title="Form | vBytes LAN Kiosk" />
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
@ -29,11 +30,24 @@ export default function Form() {
<div className="p-6 text-gray-900"> <div className="p-6 text-gray-900">
<img className="form-logo" src="/img/logo.png" /> <img className="form-logo" src="/img/logo.png" />
<h1 className='title is-3 mb-2'>Registering av deltagare</h1> <h1 className='title is-3 mb-2'>Registering av deltagare</h1>
<p className="subtitle is-6">Fyll i deltagarens och dina uppgifter. Swisha sedan in en önskad summa pengar och meddela kioskpersonal eller betala in en önskad summa pengar i kontanter till kioskpersonal.</p> <p className="subtitle is-6">Fyll i deltagarens och dina uppgifter.</p>
<p></p>
<form onSubmit={submit}> <form onSubmit={submit}>
<div className="field"> <div className="field">
<label className="label">Deltagarens förnamn och efternamn. (alt familjens efternamn om flera barn ska ha samma swishkonto)</label> <label className="label">LAN-ID</label>
<div className="control">
<TextInput
required
className="input"
type="text"
name="lan_id"
value={data.lan_id}
placeholder="LAN-ID"
onChange={(e) => setData('lan_id', e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="label">Deltagarens förnamn och efternamn.</label>
<div className="control"> <div className="control">
<TextInput <TextInput
required required
@ -80,16 +94,6 @@ export default function Form() {
</div> </div>
</div> </div>
<div className="field">
<div className="control">
<label className="checkbox">
<Checkbox type="checkbox" required/>
<span> Jag godkänner att mina och deltagarens uppgifter används.</span>
</label>
</div>
</div>
<div className="field is-grouped"> <div className="field is-grouped">
<div className="control"> <div className="control">
<button className="button is-link">Spara</button> <button className="button is-link">Spara</button>

View file

@ -0,0 +1,29 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head} from '@inertiajs/react';
import { MouseEvent } from 'react';
export default function Settings() {
const loadParticipants = (event: any) => {
event.preventDefault()
fetch("/api/load_customers");
}
return (
<AuthenticatedLayout>
<Head title="Inställningar | vBytes LAN Kiosk" />
<section className='section'>
<div className="container is-max-desktop">
<h1 className="title is-2">Inställningar</h1>
<li className='cell button is-small is-white' onClick={(event) => loadParticipants(event)}>
Hämta alla deltagare
</li>
</div>
</section>
</AuthenticatedLayout>
);
}

View file

@ -0,0 +1,291 @@
import { Head } from '@inertiajs/react';
import axios from 'axios';
import { FormEvent, useEffect, useRef, useState } from 'react';
type Step = 'lookup' | 'pay' | 'waiting' | 'done';
// Swish only reports the outcome to our server, so the page asks us — not Swish —
// how the payment went. Give up after a few minutes so it does not poll forever.
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
export default function Swish() {
const [step, setStep] = useState<Step>('lookup');
const [lanId, setLanId] = useState('');
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerName, setCustomerName] = useState('');
const [memberName, setMemberName] = useState<string | null>(null);
const [amount, setAmount] = useState('');
const [giveLeftover, setGiveLeftover] = useState(false);
const [reference, setReference] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const timers = useRef<number[]>([]);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Stop polling when the page goes away
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setStep('lookup');
setLanId('');
setAmount('');
setGiveLeftover(false);
setMemberName(null);
setReference(null);
setStatus(null);
setError('');
};
// Server errors carry a message worth showing — a network failure does not
const messageFrom = (e: unknown, fallback: string) => {
if (axios.isAxiosError(e) && e.response?.status === 429) {
return 'För många försök. Vänta en minut och prova igen.';
}
return (axios.isAxiosError(e) && e.response?.data?.message) || fallback;
};
const handleLookup = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await axios.get('/api/swish/lookup', { params: { lan_id: lanId } });
setCustomerId(res.data.customer_id);
setCustomerName(res.data.name);
setMemberName(res.data.member_name ?? null);
setStep('pay');
} catch (e) {
setError(messageFrom(e, 'Hittade ingen deltagare med det LAN-id:t.'));
} finally {
setLoading(false);
}
};
const pollStatus = (ref: string, startedAt: number) => {
const timer = window.setTimeout(async () => {
try {
const res = await axios.get('/api/swish/status', { params: { reference: ref } });
const current = res.data.status;
if (current === 'PAID' || current === 'amount_mismatch') {
setStatus(current);
setStep('done');
return;
}
if (['DECLINED', 'ERROR', 'CANCELLED', 'failed'].includes(current)) {
setStatus(current);
setStep('done');
return;
}
} catch {
// A failed poll is not a failed payment — keep trying until the timeout
}
if (Date.now() - startedAt < POLL_TIMEOUT_MS) {
pollStatus(ref, startedAt);
} else {
setStatus('timeout');
setStep('done');
}
}, POLL_INTERVAL_MS);
timers.current.push(timer);
};
const handlePay = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await axios.post('/api/swish/initiate', {
customer_id: customerId,
amount: parseInt(amount, 10),
give_leftover: giveLeftover,
});
setReference(res.data.payment_reference);
setStep('waiting');
pollStatus(res.data.payment_reference, Date.now());
window.location.href = res.data.swish_url;
} catch (e) {
setError(messageFrom(e, 'Något gick fel. Försök igen.'));
} finally {
setLoading(false);
}
};
const outcome = () => {
if (status === 'PAID') {
return {
title: '✓ Betalningen är klar!',
text: `${amount} kr är inlagt på ${memberName ? `gruppen ${customerName}` : 'ditt'} saldo.`,
tone: 'has-text-success',
};
}
if (status === 'amount_mismatch') {
return {
title: 'Beloppet stämmer inte',
text: 'Betalningen kom fram men beloppet matchade inte. Prata med en funktionär.',
tone: 'has-text-danger',
};
}
if (status === 'timeout') {
return {
title: 'Vi väntar fortfarande',
text: 'Betalningen har inte bekräftats än. Kolla ditt saldo om en stund, eller prata med en funktionär.',
tone: 'has-text-grey',
};
}
return {
title: 'Betalningen gick inte igenom',
text: 'Inget har dragits. Du kan försöka igen.',
tone: 'has-text-danger',
};
};
return (
<>
<Head title="Betala in med Swish | vBytes LAN Kiosk" />
<section className="section">
<div className="container" style={{ maxWidth: 480 }}>
<h1 className="title is-3 has-text-centered mb-6">Betala in med Swish</h1>
{step === 'lookup' && (
<div className="box">
<p>Ange ditt LAN-id för att hitta ditt konto.</p>
<p className="mb-4"> Om du är med i en grupp kommer saldot skickas till din grupp.</p>
{!isMobile && (
<div className="notification is-warning is-light">
Betalningen öppnas i Swish-appen, den här sidan behöver köras i mobilen.
</div>
)}
<form onSubmit={handleLookup}>
<div className="field">
<label className="label">LAN-id</label>
<div className="control">
<input
className="input"
type="number"
min={1}
value={lanId}
onChange={e => setLanId(e.target.value)}
required
autoFocus
/>
</div>
</div>
{error && <p className="help is-danger mb-3">{error}</p>}
<button
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
type="submit"
>
Hitta mig
</button>
</form>
</div>
)}
{step === 'pay' && (
<div className="box">
<div className="notification is-success is-light mb-6">
<strong>{memberName ?? customerName}</strong> stämmer det?
{memberName && (
<p className="mt-1">Ingår i gruppen <strong>{customerName}</strong></p>
)}
</div>
<form onSubmit={handlePay}>
<div className="field">
<label className="label">Belopp (kr)</label>
<div className="control">
<input
className="input"
type="number"
min={1}
value={amount}
onChange={e => setAmount(e.target.value)}
required
autoFocus
/>
</div>
</div>
<div className="field mt-6 mb-6">
<label className="label">Ge överblivet saldo till vBytes?</label>
<div className="control">
<label className="radio mr-4">
<input
type="radio"
className="accent-blue-600 mr-1"
checked={giveLeftover === true}
onChange={() => setGiveLeftover(true)}
/>
Ja
</label>
<label className="radio">
<input
type="radio"
className="accent-blue-600 mr-1"
checked={giveLeftover === false}
onChange={() => setGiveLeftover(false)}
/>
Nej
</label>
</div>
</div>
{error && <p className="help is-danger mb-3">{error}</p>}
<div className="buttons mt-5">
<button
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
type="submit"
>
Betala med Swish
</button>
<button
className="button is-info is-outlined is-fullwidth"
type="button"
onClick={() => { setStep('lookup'); setMemberName(null); setError(''); }}
>
Fel person? tillbaka
</button>
</div>
</form>
</div>
)}
{step === 'waiting' && (
<div className="box has-text-centered">
<p className="is-size-4 mb-4">Väntar Swish</p>
<progress className="progress is-small is-link mb-5" max="100" />
<p className="has-text-grey mb-2">
Godkänn betalningen i Swish-appen. Den här sidan uppdaterar sig själv.
</p>
{reference && (
<p className="is-size-7 has-text-grey-light">Referens: {reference}</p>
)}
</div>
)}
{step === 'done' && (
<div className="box has-text-centered">
<p className={`is-size-4 mb-4 ${outcome().tone}`}>{outcome().title}</p>
<p className="has-text-grey mb-5">{outcome().text}</p>
{reference && (
<p className="is-size-7 has-text-grey-light mb-4">Referens: {reference}</p>
)}
<button className="button is-light" onClick={reset}>
Gör en ny betalning
</button>
</div>
)}
</div>
</section>
</>
);
}

View file

@ -1,22 +0,0 @@
import { Head } from '@inertiajs/react';
export default function Thankyou() {
return (
<div>
<Head title="Tack" />
<section className='section'>
<div className="container is-max-desktop">
<div className="box px-5 py-5">
<h1 className="title px-3 py-3">Tack! Deltagaren är registrerad</h1>
<p className="subtitle is-5 px-3 py-3">Swisha in en önskad summa pengar och meddela kioskpersonal eller betala in en önskad summa pengar i kontanter till kioskpersonal.</p>
<a className='button' href="swish://">Öppna swish</a>
</div>
</div>
</section>
</div>
);
}

View file

@ -6,11 +6,12 @@ use App\Http\Controllers\CustomerController;
use App\Http\Controllers\CustomerGroupController; use App\Http\Controllers\CustomerGroupController;
use App\Http\Controllers\PurchaseController; use App\Http\Controllers\PurchaseController;
use App\Http\Controllers\DepositController; use App\Http\Controllers\DepositController;
use App\Http\Controllers\SwishController;
use App\Http\Middleware\ApiToken; use App\Http\Middleware\ApiToken;
Route::post('register_customer', [CustomerController::class, 'store'])->name('register_customer');
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::post('register_customer', [CustomerController::class, 'store'])->name('register_customer');
//Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit'); //Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit');
Route::post('update_comment', [CustomerController::class, 'updateComment'])->name('update_comment'); Route::post('update_comment', [CustomerController::class, 'updateComment'])->name('update_comment');
Route::post('register_purchase', [PurchaseController::class, 'store'])->name('register_purchase'); Route::post('register_purchase', [PurchaseController::class, 'store'])->name('register_purchase');
@ -20,6 +21,12 @@ Route::middleware('auth:sanctum')->group(function () {
Route::delete('customer/{id}', [CustomerController::class, 'destroy'])->name('delete_customer'); Route::delete('customer/{id}', [CustomerController::class, 'destroy'])->name('delete_customer');
Route::put('customer/{id}', [CustomerController::class, 'edit'])->name('edit_customer'); Route::put('customer/{id}', [CustomerController::class, 'edit'])->name('edit_customer');
Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit'); Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit');
Route::get('load_customers', [CustomerController::class, 'load'])->name('load_customer');
}); });
//Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit')->middleware([ApiToken::class]); //Route::post('register_deposit', [DepositController::class, 'store'])->name('register_deposit')->middleware([ApiToken::class]);
Route::get('swish/lookup', [SwishController::class, 'lookup']);
Route::post('swish/initiate', [SwishController::class, 'initiate']);
Route::get('swish/status', [SwishController::class, 'status']);
Route::post('swish', [SwishController::class, 'callback']);

View file

@ -1,104 +1,6 @@
<?php <?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Storage;
use App\Models\Customer;
use App\Models\Tableversion;
Schedule::call(function () { Schedule::command('app:sync-customers')->everyMinute();
Schedule::command('app:prune-stale-transactions')->hourly();
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
$client = new Client();
$responseVersions= $client->request(
'GET',
config('app.apilan_url') . "version",
[
'headers'=> [
'X-Api-Key' => config('app.apilan_key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json'
],
'cert' => config('app.apilan_clientcert_path')
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
],
);
$versions = json_decode((string) $responseVersions->getBody(), true);
if ($latestVersionParticipant === null ) {
Tableversion::create([
'table' => 'participants',
'version' => $versions['participants'] - 1,
]);
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
}
if ( $latestVersionVolunteer === null ) {
Tableversion::create([
'table' => 'volunteers',
'version' => $versions['volunteers'] - 1,
]);
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
}
if($latestVersionParticipant->version < $versions['participants'] || $latestVersionVolunteer->version < $versions['volunteers'] ) {
$response = $client->request(
'GET',
config('app.apilan_url') . "data",
[
'headers'=> [
'X-Api-Key' => config('app.apilan_key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json'
],
'cert' => config('app.apilan_clientcert_path')
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
],
);
$response_data = json_decode((string) $response->getBody(), true);
foreach ($response_data['participants'] as $participant) {
Customer::updateOrCreate(
['lan_id' => $participant['lan_id']],
[
'lan_id' => $participant['lan_id'],
'name' => $participant['first_name'] . " " . $participant['surname'],
'guardian_name' => $participant['guardian_name'],
]
);
}
foreach ($response_data['volunteers'] as $volunteer) {
Customer::updateOrCreate(
['lan_id' => $volunteer['lan_id']],
[
'lan_id' => $volunteer['lan_id'],
'name' => $volunteer['first_name'] . " " . $volunteer['surname'],
'guardian_name' => $volunteer['first_name'] . " " . $volunteer['surname']
]
);
}
if($latestVersionParticipant->version < $versions['participants']) {
Tableversion::create([
'table' => 'participants',
'version' =>$latestVersionParticipant->version + 1,
]);
}
if($latestVersionVolunteer->version < $versions['volunteers']) {
Tableversion::create([
'table' => 'volunteers',
'version' =>$latestVersionVolunteer->version + 1,
]);
}
}
})->everyMinute();

View file

@ -15,13 +15,18 @@ Route::get('/dashboard', [CustomerController::class, 'index'])->middleware(['aut
Route::get('/customer/{id}', [CustomerController::class, 'show'])->middleware(['auth', 'verified']); Route::get('/customer/{id}', [CustomerController::class, 'show'])->middleware(['auth', 'verified']);
Route::get('/customer-groups', [CustomerGroupController::class, 'index'])->middleware(['auth', 'verified'])->name('customer-groups'); Route::get('/customer-groups', [CustomerGroupController::class, 'index'])->middleware(['auth', 'verified'])->name('customer-groups');
Route::get('/form', function () { Route::get('/register_customer', function () {
return Inertia::render('Form'); return Inertia::render('Form');
})->name('form'); })->name('form')->middleware(['auth', 'verified']);
Route::get('/settings', function () {
return Inertia::render('Settings');
})->name('settings')->middleware(['auth', 'verified']);
Route::get('/swish', function () {
return Inertia::render('Swish');
})->name('swish');
Route::get('/thankyou', function () {
return Inertia::render('Thankyou');
})->name('thankyou');
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');

View file

@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\DepositController;
use App\Http\Controllers\PurchaseController;
use App\Models\Customer;
use App\Models\Deposit;
use App\Models\Purchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Deleting a deposit or a purchase has to undo exactly what creating it did.
* Neither has a route today, so the controllers are called directly.
*/
class BalanceReversalTest extends TestCase
{
use RefreshDatabase;
private function customer(array $attributes = []): Customer
{
$customer = new Customer();
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Vårdnadshavare',
'deposit' => 0,
'amount_left' => 0,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
public function test_deleting_a_deposit_takes_the_money_back_off_the_balance(): void
{
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
$deposit = Deposit::create(['customer_id' => $customer->id, 'amount' => 200]);
(new DepositController())->destroy($deposit->id);
$customer->refresh();
$this->assertSame(300, $customer->deposit);
$this->assertSame(300, $customer->amount_left);
$this->assertNull(Deposit::find($deposit->id));
}
public function test_deleting_a_group_members_deposit_reverses_it_on_the_group_account(): void
{
$group = $this->customer([
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
'is_in_group' => 0, 'customer_group_id' => 7,
]);
$member = $this->customer([
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
'is_in_group' => 1, 'customer_group_id' => 7,
]);
// Deposits from a member are recorded on the member but credited to the group
$deposit = Deposit::create(['customer_id' => $member->id, 'amount' => 200]);
(new DepositController())->destroy($deposit->id);
$this->assertSame(300, $group->refresh()->deposit);
$this->assertSame(300, $group->amount_left);
$this->assertSame(0, $member->refresh()->deposit);
}
public function test_deleting_a_purchase_gives_the_money_back(): void
{
$customer = $this->customer(['deposit' => 500, 'amount_left' => 300]);
$purchase = Purchase::create(['customer_id' => $customer->id, 'amount' => 200]);
(new PurchaseController())->destroy($purchase->id);
$customer->refresh();
$this->assertSame(500, $customer->amount_left);
// A purchase never touched the deposit, so it stays where it was
$this->assertSame(500, $customer->deposit);
$this->assertNull(Purchase::find($purchase->id));
}
public function test_a_deposit_survives_a_round_trip_through_store_and_destroy(): void
{
// lan_id deliberately differs from id, so it is clear which one store() used
$customer = $this->customer(['lan_id' => 4242, 'deposit' => 500, 'amount_left' => 500]);
$request = \Illuminate\Http\Request::create('/api/register_deposit', 'POST', [
'customer_id' => 4242,
'deposit' => 250,
'give_leftover' => 0,
]);
(new DepositController())->store($request);
$this->assertSame(750, $customer->refresh()->deposit);
(new DepositController())->destroy(Deposit::first()->id);
$customer->refresh();
$this->assertSame(500, $customer->deposit);
$this->assertSame(500, $customer->amount_left);
}
public function test_amount_left_can_be_mass_assigned_now_that_the_key_is_spelled_right(): void
{
$customer = Customer::create([
'lan_id' => 5,
'name' => 'Test Testsson',
'guardian_name' => 'Vårdnadshavare',
'deposit' => 100,
'amount_left' => 100,
]);
$this->assertSame(100, $customer->refresh()->amount_left);
}
}

View file

@ -0,0 +1,187 @@
<?php
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\CustomerGroup;
use App\Models\Deposit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Adding someone to an existing group, and taking a group apart again.
*/
class CustomerGroupChangesTest extends TestCase
{
use RefreshDatabase;
private function customer(array $attributes = []): Customer
{
$customer = new Customer();
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Karin Olsson',
'deposit' => 0,
'amount_left' => 0,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
/** A group and its own customer row, the way store() leaves them */
private function group(int $balance = 0): array
{
$group = CustomerGroup::create(['name' => 'Klanen']);
$groupCustomer = $this->customer([
'lan_id' => 10,
'name' => 'Klanen',
'deposit' => $balance,
'amount_left' => $balance,
'is_in_group' => 0,
'customer_group_id' => $group->id,
]);
return [$group, $groupCustomer];
}
private function asUser(): self
{
$this->actingAs(User::factory()->create(), 'sanctum');
return $this;
}
public function test_adding_a_member_moves_their_balance_to_the_group(): void
{
[$group, $groupCustomer] = $this->group(800);
$anna = $this->customer(['lan_id' => 11, 'name' => 'Anna', 'deposit' => 300, 'amount_left' => 300]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id],
])->assertOk();
$this->assertSame(1100, $groupCustomer->refresh()->amount_left);
$this->assertSame(1100, $groupCustomer->deposit);
$anna->refresh();
$this->assertSame(1, (int) $anna->is_in_group);
$this->assertSame($group->id, $anna->customer_group_id);
}
public function test_a_member_who_has_spent_money_only_brings_what_is_left(): void
{
// Anna put in 300 and has spent 200, so 100 is hers to bring
[$group, $groupCustomer] = $this->group(800);
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 100]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id],
])->assertOk();
$this->assertSame(900, $groupCustomer->refresh()->amount_left);
$this->assertSame(900, $groupCustomer->deposit);
}
public function test_the_balance_is_cleared_on_the_member_so_it_is_not_counted_twice(): void
{
[$group] = $this->group(800);
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id],
]);
$anna->refresh();
$this->assertSame(0, $anna->deposit);
$this->assertSame(0, $anna->amount_left);
}
public function test_the_moved_balance_is_recorded_as_a_deposit(): void
{
[$group, $groupCustomer] = $this->group(800);
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id],
]);
$this->assertSame(300, Deposit::where('customer_id', $groupCustomer->id)->sum('amount'));
}
public function test_adding_several_members_at_once_adds_up(): void
{
[$group, $groupCustomer] = $this->group(0);
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
$bo = $this->customer(['lan_id' => 12, 'deposit' => 200, 'amount_left' => 150]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id, $bo->id],
]);
$this->assertSame(450, $groupCustomer->refresh()->amount_left);
}
public function test_adding_to_a_group_without_an_account_row_gives_404(): void
{
$group = CustomerGroup::create(['name' => 'Tom grupp']);
$anna = $this->customer(['lan_id' => 11]);
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
'customers' => [$anna->id],
])->assertStatus(404);
}
public function test_deleting_a_group_keeps_the_participants(): void
{
[$group, $groupCustomer] = $this->group(0);
$anna = $this->customer([
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => $group->id,
]);
$this->asUser()->deleteJson('/api/customer-group/' . $group->id)->assertOk();
// Anna is a real participant and has to survive the group being dissolved
$anna->refresh();
$this->assertNotNull($anna);
$this->assertSame(0, (int) $anna->is_in_group);
$this->assertNull($anna->customer_group_id);
// The group and the row that only existed to hold its balance are gone
$this->assertNull(Customer::find($groupCustomer->id));
$this->assertNull(CustomerGroup::find($group->id));
}
public function test_a_group_with_money_left_is_not_deleted(): void
{
[$group, $groupCustomer] = $this->group(450);
$anna = $this->customer([
'lan_id' => 11, 'is_in_group' => 1, 'customer_group_id' => $group->id,
]);
$this->asUser()->deleteJson('/api/customer-group/' . $group->id)
->assertStatus(422);
$this->assertNotNull(CustomerGroup::find($group->id));
$this->assertNotNull(Customer::find($groupCustomer->id));
$this->assertSame(1, (int) $anna->refresh()->is_in_group);
}
public function test_the_endpoints_are_closed_to_anyone_not_logged_in(): void
{
[$group] = $this->group(0);
$anna = $this->customer(['lan_id' => 11]);
$this->postJson('/api/add-to-customer-group/' . $group->id, ['customers' => [$anna->id]])
->assertStatus(401);
$this->deleteJson('/api/customer-group/' . $group->id)->assertStatus(401);
$this->assertNotNull(CustomerGroup::find($group->id));
}
}

View file

@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\CustomerGroup;
use App\Models\Deposit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CustomerGroupTest extends TestCase
{
use RefreshDatabase;
private function customer(array $attributes = []): Customer
{
$customer = new Customer();
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Karin Olsson',
'deposit' => 0,
'amount_left' => 0,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
/** The group endpoints sit behind auth:sanctum */
private function asUser(): self
{
$this->actingAs(User::factory()->create(), 'sanctum');
return $this;
}
public function test_it_creates_a_group_with_a_customer_row_of_its_own(): void
{
$erik = $this->customer(['lan_id' => 1, 'name' => 'Erik', 'guardian_name' => 'Karin']);
$anna = $this->customer(['lan_id' => 2, 'name' => 'Anna']);
$this->asUser()->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id, $anna->id],
])->assertRedirect('customer-groups/');
$group = CustomerGroup::firstWhere('name', 'Klanen');
$this->assertNotNull($group);
// The group gets its own customer row, which is where the balance lives
$groupCustomer = Customer::where('customer_group_id', $group->id)
->where('is_in_group', 0)
->first();
$this->assertSame('Klanen', $groupCustomer->name);
// The guardian is taken from the first member in the list
$this->assertSame('Karin', $groupCustomer->guardian_name);
}
public function test_the_members_are_marked_as_belonging_to_the_group(): void
{
$erik = $this->customer(['lan_id' => 1, 'name' => 'Erik']);
$anna = $this->customer(['lan_id' => 2, 'name' => 'Anna']);
$this->asUser()->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id, $anna->id],
]);
$group = CustomerGroup::firstWhere('name', 'Klanen');
foreach ([$erik, $anna] as $member) {
$member->refresh();
$this->assertSame(1, (int) $member->is_in_group);
$this->assertSame($group->id, $member->customer_group_id);
}
}
public function test_the_members_balances_are_moved_to_the_group(): void
{
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 500]);
$anna = $this->customer(['lan_id' => 2, 'deposit' => 300, 'amount_left' => 300]);
$this->asUser()->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id, $anna->id],
]);
$group = CustomerGroup::firstWhere('name', 'Klanen');
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
$this->assertSame(800, $groupCustomer->amount_left);
$this->assertSame(800, $groupCustomer->deposit);
// Nothing is left behind on the members
$this->assertSame(0, $erik->refresh()->amount_left);
$this->assertSame(0, $erik->deposit);
$this->assertSame(0, $anna->refresh()->amount_left);
$this->assertSame(0, $anna->deposit);
}
public function test_the_moved_balance_is_recorded_as_a_deposit_on_the_group(): void
{
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 500]);
$this->asUser()->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id],
]);
$group = CustomerGroup::firstWhere('name', 'Klanen');
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
$this->assertSame(1, Deposit::where('customer_id', $groupCustomer->id)->count());
$this->assertSame(500, Deposit::where('customer_id', $groupCustomer->id)->first()->amount);
}
public function test_what_a_member_has_already_spent_does_not_follow_along(): void
{
// Erik put in 500 and has spent 200, so 300 is what he can still use
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 300]);
$this->asUser()->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id],
]);
$group = CustomerGroup::firstWhere('name', 'Klanen');
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
// The group takes over what is left, and its deposit is set to the same
// number — the 200 already spent is not carried over as history
$this->assertSame(300, $groupCustomer->amount_left);
$this->assertSame(300, $groupCustomer->deposit);
}
public function test_it_requires_a_name_and_at_least_one_customer(): void
{
$this->asUser()->postJson('/api/customer-group', ['group_name' => 'Klanen'])
->assertStatus(422);
$this->asUser()->postJson('/api/customer-group', ['customers' => [1]])
->assertStatus(422);
$this->assertSame(0, CustomerGroup::count());
}
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
{
$erik = $this->customer();
$this->postJson('/api/customer-group', [
'group_name' => 'Klanen',
'customers' => [$erik->id],
])->assertStatus(401);
$this->assertSame(0, CustomerGroup::count());
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\CustomerGroup;
use App\Models\Deposit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Registering a deposit over the counter.
*/
class DepositTest extends TestCase
{
use RefreshDatabase;
private function customer(array $attributes = []): Customer
{
$customer = new Customer();
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Karin Olsson',
'deposit' => 0,
'amount_left' => 0,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
private function asUser(): self
{
$this->actingAs(User::factory()->create(), 'sanctum');
return $this;
}
public function test_a_deposit_is_credited_and_recorded(): void
{
$customer = $this->customer(['lan_id' => 42, 'deposit' => 100, 'amount_left' => 100]);
$this->asUser()->postJson('/api/register_deposit', [
'customer_id' => 42,
'deposit' => 250,
'give_leftover' => 1,
])->assertOk();
$customer->refresh();
$this->assertSame(350, $customer->deposit);
$this->assertSame(350, $customer->amount_left);
$this->assertSame(1, (int) $customer->give_leftover);
$this->assertSame(250, Deposit::first()->amount);
}
public function test_a_group_members_deposit_lands_on_the_group(): void
{
$group = CustomerGroup::create(['name' => 'Klanen']);
$groupCustomer = $this->customer([
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
'is_in_group' => 0, 'customer_group_id' => $group->id,
]);
$member = $this->customer([
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => $group->id,
]);
$this->asUser()->postJson('/api/register_deposit', [
'customer_id' => 11,
'deposit' => 200,
'give_leftover' => 0,
])->assertOk();
$this->assertSame(700, $groupCustomer->refresh()->deposit);
$this->assertSame(700, $groupCustomer->amount_left);
$this->assertSame(0, $member->refresh()->deposit);
// The receipt is kept on the person who paid, the balance on the group
$this->assertSame($member->id, Deposit::first()->customer_id);
}
public function test_an_unknown_customer_gives_404_and_writes_nothing(): void
{
$this->asUser()->postJson('/api/register_deposit', [
'customer_id' => 999999,
'deposit' => 250,
'give_leftover' => 0,
])->assertStatus(404);
$this->assertSame(0, Deposit::count());
}
public function test_a_member_whose_group_row_is_missing_gives_404_and_writes_nothing(): void
{
// A broken state: the member points at a group that has no account row
$member = $this->customer([
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => 999,
]);
$this->asUser()->postJson('/api/register_deposit', [
'customer_id' => 11,
'deposit' => 200,
'give_leftover' => 0,
])->assertStatus(404);
// No receipt for money that was never credited anywhere
$this->assertSame(0, Deposit::count());
$this->assertSame(0, $member->refresh()->deposit);
}
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
{
$customer = $this->customer(['lan_id' => 42]);
$this->postJson('/api/register_deposit', [
'customer_id' => 42,
'deposit' => 250,
'give_leftover' => 0,
])->assertStatus(401);
$this->assertSame(0, Deposit::count());
$this->assertSame(0, $customer->refresh()->deposit);
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\Purchase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PurchaseTest extends TestCase
{
use RefreshDatabase;
private function customer(array $attributes = []): Customer
{
$customer = new Customer();
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Karin Olsson',
'deposit' => 500,
'amount_left' => 500,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
private function asUser(): self
{
$this->actingAs(User::factory()->create(), 'sanctum');
return $this;
}
public function test_a_purchase_is_recorded_and_taken_off_the_balance(): void
{
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => 120,
])->assertRedirect('customer/' . $customer->id);
$customer->refresh();
$this->assertSame(380, $customer->amount_left);
// What was paid in is history and does not change when something is bought
$this->assertSame(500, $customer->deposit);
$purchase = Purchase::first();
$this->assertSame($customer->id, $purchase->customer_id);
$this->assertSame(120, $purchase->amount);
}
public function test_several_purchases_add_up(): void
{
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
foreach ([100, 50, 25] as $amount) {
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => $amount,
]);
}
$this->assertSame(325, $customer->refresh()->amount_left);
$this->assertSame(3, Purchase::count());
}
public function test_the_purchase_shows_up_on_the_customer(): void
{
$customer = $this->customer();
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => 120,
]);
$this->assertSame(1, $customer->purchases()->count());
$this->assertSame(120, $customer->purchases()->first()->amount);
}
public function test_a_purchase_larger_than_the_balance_makes_it_negative(): void
{
// Nothing stops this today — the kiosk staff is trusted to check the balance
$customer = $this->customer(['deposit' => 100, 'amount_left' => 100]);
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => 250,
]);
$this->assertSame(-150, $customer->refresh()->amount_left);
}
public function test_a_group_purchase_is_taken_from_the_group_balance(): void
{
// Purchases are registered from the account page, so for a group it is the
// group's own customer row that is charged
$group = $this->customer([
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 800, 'amount_left' => 800,
'is_in_group' => 0, 'customer_group_id' => 7,
]);
$this->customer([
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
'is_in_group' => 1, 'customer_group_id' => 7,
]);
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $group->id,
'amount' => 200,
]);
$this->assertSame(600, $group->refresh()->amount_left);
}
public function test_it_requires_a_customer(): void
{
$this->asUser()->postJson('/api/register_purchase', ['amount' => 120])
->assertStatus(422);
$this->assertSame(0, Purchase::count());
}
public function test_an_unknown_customer_gives_404_and_writes_nothing(): void
{
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => 999999,
'amount' => 120,
])->assertStatus(404);
// No line item for a purchase that no balance ever paid for
$this->assertSame(0, Purchase::count());
}
public function test_the_purchase_and_the_deduction_are_written_together(): void
{
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
$this->asUser()->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => 120,
]);
// The balance always matches what the line items add up to
$this->assertSame(1, Purchase::count());
$this->assertSame(500 - (int) Purchase::sum('amount'), $customer->refresh()->amount_left);
}
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
{
$customer = $this->customer();
$this->postJson('/api/register_purchase', [
'customer_id' => $customer->id,
'amount' => 120,
])->assertStatus(401);
$this->assertSame(500, $customer->refresh()->amount_left);
$this->assertSame(0, Purchase::count());
}
}

View file

@ -0,0 +1,185 @@
<?php
namespace Tests\Feature;
use App\Models\Deposit;
use App\Models\Transaction;
/**
* The callback is where money is created out of an HTTP request, so this is the
* file to read first if something about the payment flow ever feels uncertain.
*/
class SwishCallbackTest extends SwishTestCase
{
public function test_a_paid_callback_credits_the_customer(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload())->assertNoContent();
$customer->refresh();
$this->assertSame(800, $customer->deposit);
$this->assertSame(800, $customer->amount_left);
$this->assertSame('PAID', Transaction::first()->status);
$this->assertSame(1, Deposit::where('customer_id', $customer->id)->count());
}
public function test_it_rejects_a_callback_without_the_identifier(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id]);
$this->postCallback($this->paidPayload(), null)->assertStatus(403);
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame('pending', Transaction::first()->status);
}
public function test_it_rejects_a_callback_with_the_wrong_identifier(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id]);
$this->postCallback($this->paidPayload(), 'ffffffff-ffff-ffff-ffff-ffffffffffff')
->assertStatus(403);
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame('pending', Transaction::first()->status);
}
public function test_it_rejects_a_callback_for_a_transaction_that_was_never_registered(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'callback_identifier' => null]);
$this->postCallback($this->paidPayload())->assertStatus(403);
$this->assertSame(700, $customer->refresh()->deposit);
}
public function test_a_repeated_callback_only_credits_once(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload())->assertNoContent();
$this->postCallback($this->paidPayload())->assertNoContent();
$this->postCallback($this->paidPayload())->assertNoContent();
$this->assertSame(800, $customer->refresh()->deposit);
$this->assertSame(1, Deposit::count());
}
public function test_it_refuses_an_amount_that_differs_from_the_payment_request(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload(['amount' => 5000]))->assertNoContent();
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame('amount_mismatch', Transaction::first()->status);
$this->assertSame(0, Deposit::count());
}
public function test_it_does_not_round_an_amount_with_ore(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload(['amount' => 99.50]))->assertNoContent();
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame('amount_mismatch', Transaction::first()->status);
}
public function test_a_declined_payment_is_recorded_but_not_credited(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id]);
$this->postCallback($this->paidPayload([
'status' => 'DECLINED',
'errorCode' => 'BANKIDCL',
]))->assertNoContent();
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame('DECLINED', Transaction::first()->status);
}
public function test_an_unknown_reference_changes_nothing(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->postCallback($this->paidPayload(['payeePaymentReference' => 'LAN-NOSUCHREF']))
->assertNoContent();
$this->assertSame(700, $customer->refresh()->deposit);
$this->assertSame(0, Deposit::count());
}
public function test_a_group_member_payment_lands_on_the_group_account(): void
{
$group = $this->customer([
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
'is_in_group' => 0, 'customer_group_id' => 7,
]);
$member = $this->customer([
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
'is_in_group' => 1, 'customer_group_id' => 7,
]);
$this->transaction(['customer_id' => $member->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload())->assertNoContent();
$this->assertSame(600, $group->refresh()->deposit);
$this->assertSame(600, $group->amount_left);
$this->assertSame(0, $member->refresh()->deposit);
}
public function test_a_payment_to_a_customer_of_their_own_lands_on_themselves(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700, 'is_in_group' => 0]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$this->postCallback($this->paidPayload())->assertNoContent();
$this->assertSame(800, $customer->refresh()->deposit);
}
public function test_a_late_callback_on_an_expired_transaction_is_still_booked(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100, 'status' => 'expired']);
$this->postCallback($this->paidPayload())->assertNoContent();
$this->assertSame(800, $customer->refresh()->deposit);
$this->assertSame('PAID', Transaction::first()->status);
}
public function test_the_leftover_choice_from_the_payment_follows_along(): void
{
$customer = $this->customer(['deposit' => 0, 'amount_left' => 0, 'give_leftover' => 0]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100, 'give_leftover' => 1]);
$this->postCallback($this->paidPayload())->assertNoContent();
$this->assertSame(1, (int) $customer->refresh()->give_leftover);
}
public function test_it_still_understands_the_old_reference_in_the_message_field(): void
{
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
$payload = $this->paidPayload(['message' => 'kiosk|LAN-TEST0001']);
unset($payload['payeePaymentReference']);
$this->postCallback($payload)->assertNoContent();
$this->assertSame(800, $customer->refresh()->deposit);
}
}

View file

@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Models\Transaction;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
class SwishInitiateTest extends SwishTestCase
{
public function test_it_registers_the_payment_with_swish_and_returns_an_app_switch_url(): void
{
$customer = $this->customer();
$this->fakeSwishAcceptsWithToken('TOKEN123');
$response = $this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 250,
'give_leftover' => true,
]);
$response->assertOk();
$this->assertStringStartsWith('swish://paymentrequest?token=TOKEN123', $response->json('swish_url'));
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
$this->assertSame('pending', $transaction->status);
$this->assertSame(250, $transaction->expected_amount);
$this->assertNotEmpty($transaction->callback_identifier);
}
public function test_the_request_to_swish_has_the_fields_swish_handel_requires(): void
{
$customer = $this->customer();
$this->fakeSwishAcceptsWithToken();
$response = $this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 250,
'give_leftover' => false,
]);
$body = $this->lastSwishBody();
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
$this->assertSame('1231181189', $body['payeeAlias']);
$this->assertSame('SEK', $body['currency']);
$this->assertSame('250.00', $body['amount']);
$this->assertSame('https://swish.example.se/webhook/swish/kiosk', $body['callbackUrl']);
$this->assertSame($transaction->payment_reference, $body['payeePaymentReference']);
$this->assertSame($transaction->callback_identifier, $body['callbackIdentifier']);
// The reference has to survive Swish's own validation
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9-]{1,35}$/', $body['payeePaymentReference']);
$this->assertMatchesRegularExpression('/^[0-9a-zA-Z-]{32,36}$/', $body['callbackIdentifier']);
$this->assertLessThanOrEqual(50, strlen($body['message']));
}
public function test_the_payment_request_is_a_put_to_the_instruction_uuid(): void
{
$customer = $this->customer();
$this->fakeSwishAcceptsWithToken();
$response = $this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 100,
'give_leftover' => false,
]);
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
$request = end($this->swishRequests)['request'];
$this->assertSame('PUT', $request->getMethod());
$this->assertSame(
'/swish-cpcapi/api/v2/paymentrequests/' . $transaction->instruction_uuid,
$request->getUri()->getPath()
);
$this->assertMatchesRegularExpression('/^[0-9A-F]{32}$/', $transaction->instruction_uuid);
}
public function test_it_fails_cleanly_when_swish_is_not_configured(): void
{
config(['app.swish_api_url' => '']);
$customer = $this->customer();
$response = $this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 100,
'give_leftover' => false,
]);
$response->assertStatus(502);
$this->assertSame('failed', Transaction::first()->status);
}
public function test_it_fails_when_swish_rejects_the_payment_request(): void
{
$customer = $this->customer();
$this->fakeSwish(new GuzzleResponse(422, [], json_encode([
['errorCode' => 'PA02', 'errorMessage' => 'Amount value is missing or not a valid number'],
])));
$response = $this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 100,
'give_leftover' => false,
]);
$response->assertStatus(502);
$this->assertSame('failed', Transaction::first()->status);
}
public function test_it_fails_when_swish_answers_without_a_token(): void
{
$customer = $this->customer();
$this->fakeSwish(new GuzzleResponse(201));
$this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 100,
'give_leftover' => false,
])->assertStatus(502);
$this->assertSame('failed', Transaction::first()->status);
}
public function test_it_validates_its_input(): void
{
$customer = $this->customer();
$this->postJson('/api/swish/initiate', [
'customer_id' => $customer->id,
'amount' => 0,
'give_leftover' => false,
])->assertStatus(422);
$this->postJson('/api/swish/initiate', [
'customer_id' => 999999,
'amount' => 100,
'give_leftover' => false,
])->assertStatus(422);
$this->assertSame(0, Transaction::count());
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use App\Models\Transaction;
class SwishLookupAndStatusTest extends SwishTestCase
{
public function test_it_finds_a_customer_by_lan_id(): void
{
$customer = $this->customer(['lan_id' => 42, 'name' => 'Erik Olsson']);
$this->getJson('/api/swish/lookup?lan_id=42')
->assertOk()
->assertJson([
'customer_id' => $customer->id,
'name' => 'Erik O.',
'member_name' => null,
]);
}
public function test_the_endpoint_never_hands_out_a_full_name(): void
{
// Anyone can call this without logging in, and lan_id is easy to guess,
// so a surname must not be readable in the answer
$this->customer(['lan_id' => 42, 'name' => 'Erik Olsson']);
$response = $this->getJson('/api/swish/lookup?lan_id=42');
$response->assertOk();
$this->assertStringNotContainsString('Olsson', $response->getContent());
}
public function test_a_name_with_several_surnames_is_masked_all_the_way(): void
{
$this->customer(['lan_id' => 42, 'name' => 'Anna Maria Svensson Berg']);
$this->getJson('/api/swish/lookup?lan_id=42')
->assertOk()
->assertJson(['name' => 'Anna M. S. B.']);
}
public function test_a_group_member_is_resolved_to_the_group_account(): void
{
$group = $this->customer([
'lan_id' => 10, 'name' => 'Klanen', 'is_in_group' => 0, 'customer_group_id' => 7,
]);
$this->customer([
'lan_id' => 11, 'name' => 'Anna Svensson', 'is_in_group' => 1, 'customer_group_id' => 7,
]);
// The payment goes to the group, but the page should greet Anna — and the
// group's own name is a chosen label, so it is shown in full
$this->getJson('/api/swish/lookup?lan_id=11')
->assertOk()
->assertJson([
'customer_id' => $group->id,
'name' => 'Klanen',
'member_name' => 'Anna S.',
]);
}
public function test_an_unknown_lan_id_gives_404(): void
{
$this->getJson('/api/swish/lookup?lan_id=999')->assertStatus(404);
}
public function test_a_member_without_a_group_account_gives_404(): void
{
$this->customer(['lan_id' => 11, 'is_in_group' => 1, 'customer_group_id' => 7]);
$this->getJson('/api/swish/lookup?lan_id=11')->assertStatus(404);
}
public function test_the_status_endpoint_reports_the_payment(): void
{
$customer = $this->customer();
$this->transaction(['customer_id' => $customer->id, 'status' => 'PAID', 'expected_amount' => 120]);
$this->getJson('/api/swish/status?reference=LAN-TEST0001')
->assertOk()
->assertJson(['status' => 'PAID', 'amount' => 120]);
}
public function test_the_status_endpoint_never_exposes_the_callback_identifier(): void
{
$customer = $this->customer();
$this->transaction(['customer_id' => $customer->id]);
$response = $this->getJson('/api/swish/status?reference=LAN-TEST0001');
$response->assertOk();
$this->assertStringNotContainsString('aaaaaaaa-bbbb', $response->getContent());
}
public function test_an_unknown_reference_gives_404(): void
{
$this->getJson('/api/swish/status?reference=LAN-NOSUCHREF')->assertStatus(404);
}
public function test_stale_transactions_are_expired_rather_than_deleted(): void
{
$customer = $this->customer();
$this->transaction(['customer_id' => $customer->id, 'payment_reference' => 'LAN-OLD00001'])
->forceFill(['created_at' => now()->subHours(3)])->save();
$this->transaction(['customer_id' => $customer->id, 'payment_reference' => 'LAN-NEW00001']);
$this->artisan('app:prune-stale-transactions')->assertSuccessful();
$this->assertSame('expired', Transaction::firstWhere('payment_reference', 'LAN-OLD00001')->status);
$this->assertSame('pending', Transaction::firstWhere('payment_reference', 'LAN-NEW00001')->status);
$this->assertSame(2, Transaction::count());
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\Transaction;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Shared setup for the Swish tests.
*
* Runs against an in-memory sqlite database (see phpunit.xml), so nothing here
* can touch the real one, and no call ever leaves the machine.
*/
abstract class SwishTestCase extends TestCase
{
use RefreshDatabase;
/** Requests the fake Swish API received */
protected array $swishRequests = [];
protected function setUp(): void
{
parent::setUp();
config([
'app.swish_api_url' => 'https://mss.cpc.getswish.net',
'app.swish_payee_alias' => '1231181189',
'app.swish_callback_url' => 'https://swish.example.se/webhook/swish',
]);
}
/**
* Stand in for the Swish API. Without a queued response the controller would
* make a real call, so every test that reaches it has to set this up.
*/
protected function fakeSwish(GuzzleResponse $response): void
{
$stack = HandlerStack::create(new MockHandler([$response]));
$stack->push(Middleware::history($this->swishRequests));
$this->app->instance(Client::class, new Client(['handler' => $stack]));
}
protected function fakeSwishAcceptsWithToken(string $token = 'TOKEN123'): void
{
$this->fakeSwish(new GuzzleResponse(201, ['PaymentRequestToken' => $token]));
}
/** The JSON body of the last request sent to Swish */
protected function lastSwishBody(): array
{
$request = end($this->swishRequests)['request'];
return json_decode((string) $request->getBody(), true);
}
protected function customer(array $attributes = []): Customer
{
$customer = new Customer();
// forceFill because the model's $fillable spells amount_left with a space
$customer->forceFill(array_merge([
'lan_id' => 1,
'name' => 'Erik Olsson',
'guardian_name' => 'Vårdnadshavare',
'deposit' => 0,
'amount_left' => 0,
'give_leftover' => 0,
'is_in_group' => 0,
], $attributes))->save();
return $customer;
}
protected function transaction(array $attributes = []): Transaction
{
return Transaction::create(array_merge([
'payment_reference' => 'LAN-TEST0001',
'customer_id' => 1,
'expected_amount' => 100,
'status' => 'pending',
'give_leftover' => 0,
'source' => 'kiosk',
'instruction_uuid' => 'AB23D7406ECE4542A80152D909EF9F6B',
'callback_identifier' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
], $attributes));
}
/**
* Post a callback the way Swish would, with the identifier as a header.
*/
protected function postCallback(array $payload, ?string $identifier = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
{
$headers = $identifier === null ? [] : ['callbackIdentifier' => $identifier];
return $this->postJson('/api/swish', $payload, $headers);
}
protected function paidPayload(array $overrides = []): array
{
return array_merge([
'id' => 'AB23D7406ECE4542A80152D909EF9F6B',
'payeePaymentReference' => 'LAN-TEST0001',
'paymentReference' => '6D6CD7406ECE4542A80152D909EF9F6B',
'payerAlias' => '46701234567',
'payeeAlias' => '1231181189',
'amount' => 100,
'currency' => 'SEK',
'message' => 'vBytes LAN',
'status' => 'PAID',
'errorCode' => null,
'errorMessage' => '',
], $overrides);
}
}