diff --git a/.env.example b/.env.example
index 35db1dd..fbe9aa2 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,42 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
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=
diff --git a/app/Console/Commands/PruneStaleTransactions.php b/app/Console/Commands/PruneStaleTransactions.php
new file mode 100644
index 0000000..e7502a6
--- /dev/null
+++ b/app/Console/Commands/PruneStaleTransactions.php
@@ -0,0 +1,24 @@
+where('created_at', '<', now()->subHours(2))
+ ->update(['status' => 'expired']);
+
+ $this->info("Marked {$expired} stale transaction(s) as expired.");
+ }
+}
diff --git a/app/Console/Commands/SyncCustomers.php b/app/Console/Commands/SyncCustomers.php
new file mode 100644
index 0000000..7edf818
--- /dev/null
+++ b/app/Console/Commands/SyncCustomers.php
@@ -0,0 +1,115 @@
+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'),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/CustomerController.php b/app/Http/Controllers/CustomerController.php
index 732b94f..9fc45db 100644
--- a/app/Http/Controllers/CustomerController.php
+++ b/app/Http/Controllers/CustomerController.php
@@ -7,6 +7,12 @@ use App\Models\CustomerGroup;
use Illuminate\Http\Request;
use App\Models\Purchase;
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
{
@@ -34,18 +40,120 @@ class CustomerController extends Controller
public function store(Request $request)
{
$request->validate([
+ 'lan_id' => ['required'],
'name' => 'required',
'guardian_name' => 'required',
'give_leftover' => 'nullable',
]);
- Customer::create([
+ $customer = Customer::create([
+ 'lan_id' => $request->lan_id,
'name' => $request->name,
'guardian_name' => $request->guardian_name,
'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);
$groupmembers = Customer::where('is_in_group', 1)->where('customer_group_id', $customer->customer_group_id)->get();
-
- return Inertia::render('Customer', ['customer' => $customer, 'groupmembers' => $groupmembers]);
+ return Inertia::render('Customer', [
+ 'customer' => $customer,
+ 'groupmembers' => $groupmembers,
+ ]);
}
/**
diff --git a/app/Http/Controllers/CustomerGroupController.php b/app/Http/Controllers/CustomerGroupController.php
index 11e9612..5abd299 100644
--- a/app/Http/Controllers/CustomerGroupController.php
+++ b/app/Http/Controllers/CustomerGroupController.php
@@ -59,13 +59,7 @@ class CustomerGroupController extends Controller
$groupAmount= 0;
foreach ($customers as $customerItem) {
- $customer = Customer::findOrFail($customerItem);
- $customer->customer_group_id = $customerGroup->id;
- $customer->is_in_group = 1;
- $groupAmount += $customer->amount_left;
- $customer->deposit = 0;
- $customer->amount_left = 0;
- $customer->save();
+ $groupAmount += Customer::findOrFail($customerItem)->joinGroup($customerGroup->id);
}
Deposit::create([
@@ -110,17 +104,29 @@ class CustomerGroupController extends Controller
$customers = $request->customers;
$groupCustomer = Customer::where('customer_group_id', $id)->where('is_in_group', 0)->first();
- foreach ($customers as $customerItem) {
- $customer = Customer::findOrFail($customerItem);
- $groupCustomer->deposit += $customer->deposit;
- $groupCustomer->amount_left += $customer->deposit;
- $groupCustomer->save();
- $customer->customer_group_id = $id;
- $customer->is_in_group = 1;
- $customer->deposit = 0;
- $customer->save();
+ if (!$groupCustomer) {
+ return response()->json([
+ 'success' => false, 'message' => 'Customer group not found'
+ ], 404);
}
-
+
+ $movedAmount = 0;
+
+ foreach ($customers as $customerItem) {
+ $movedAmount += Customer::findOrFail($customerItem)->joinGroup($id);
+ }
+
+ $groupCustomer->deposit += $movedAmount;
+ $groupCustomer->amount_left += $movedAmount;
+ $groupCustomer->save();
+
+ if ($movedAmount > 0) {
+ Deposit::create([
+ 'customer_id' => $groupCustomer->id,
+ 'amount' => $movedAmount,
+ ]);
+ }
+
return response()->json([
'success' => true, 'message' => 'Customer group was updated'
]);
@@ -132,10 +138,27 @@ class CustomerGroupController extends Controller
public function destroy($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();
-
+
return response()->json([
'success' => true, 'message' => 'Customer group deleted successfully'
]);
diff --git a/app/Http/Controllers/DepositController.php b/app/Http/Controllers/DepositController.php
index aae5248..9825573 100644
--- a/app/Http/Controllers/DepositController.php
+++ b/app/Http/Controllers/DepositController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Deposit;
use App\Models\Customer;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
class DepositController extends Controller
{
@@ -41,45 +42,55 @@ class DepositController extends Controller
} else {
$customer = Customer::where('lan_id',$request->customer_id)->first();
}
-
- Deposit::create([
- 'customer_id' => $customer->id,
- 'amount' => $request->deposit,
- ]);
-
-
-
- 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) {
- return redirect('customer/' . $request->customer_id);
- }
+ if (!$customer) {
return response()->json([
- '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'
- ]);
+ '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([
+ 'customer_id' => $customer->id,
+ 'amount' => $amount,
+ ]);
+ });
+
+ 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)
{
$deposit = Deposit::findOrFail( $id );
- $customer = Customer::where('id', $deposit->customer_id);
- $customer->amount_left = $customer->amount_left + $deposit->amount;
- $customer->amount_used = $customer->amount_used - $deposit->amount;
- $customer->save();
+ $customer = Customer::findOrFail( $deposit->customer_id );
+ $amount = (int) $deposit->amount;
+
+ // 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();
return response()->json([
diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php
index f776317..a871769 100644
--- a/app/Http/Controllers/PurchaseController.php
+++ b/app/Http/Controllers/PurchaseController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Purchase;
use Illuminate\Http\Request;
use App\Models\Customer;
+use Illuminate\Support\Facades\DB;
class PurchaseController extends Controller
{
@@ -35,14 +36,20 @@ class PurchaseController extends Controller
'amount' => 'nullable',
]);
- Purchase::create([
- 'customer_id' => $data['customer_id'],
- 'amount' => $data['amount'],
- ]);
-
+ // Look the customer up before writing anything — creating the purchase
+ // first would leave a line item behind that no balance ever paid for
$customer = Customer::findOrFail( $data['customer_id'] );
- $customer->amount_left = $customer->amount_left - $data['amount'];
- $customer->save();
+ $amount = (int) $data['amount'];
+
+ DB::transaction(function () use ($customer, $amount) {
+ $customer->amount_left = $customer->amount_left - $amount;
+ $customer->save();
+
+ Purchase::create([
+ 'customer_id' => $customer->id,
+ 'amount' => $amount,
+ ]);
+ });
return redirect('customer/' . $customer->id);
}
@@ -77,10 +84,13 @@ class PurchaseController extends Controller
public function destroy($id)
{
$purchase = Purchase::findOrFail( $id );
- $customer = Customer::where('id', $purchase->customer_id);
- $customer->amount_left = $customer->amount_left + $purchase->amount;
- $customer->amount_used = $customer->amount_used - $purchase->amount;
+ $customer = Customer::findOrFail( $purchase->customer_id );
+
+ // 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();
+
$purchase->delete();
return response()->json([
diff --git a/app/Http/Controllers/SwishController.php b/app/Http/Controllers/SwishController.php
new file mode 100644
index 0000000..1d55a91
--- /dev/null
+++ b/app/Http/Controllers/SwishController.php
@@ -0,0 +1,379 @@
+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, '|');
+ }
+}
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
index 8a3b4f8..90aaeed 100644
--- a/app/Models/Customer.php
+++ b/app/Models/Customer.php
@@ -17,8 +17,7 @@ class Customer extends Model
'lan_id',
'name',
'guardian_name',
- 'amount left',
- 'amount used',
+ 'amount_left',
'deposit',
'give_leftover',
'comment',
@@ -26,6 +25,26 @@ class Customer extends Model
'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.
*/
diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php
new file mode 100644
index 0000000..3a96c12
--- /dev/null
+++ b/app/Models/Transaction.php
@@ -0,0 +1,24 @@
+ env('API_KEY_DEPOSIT'),
'apilan_key' => env('API_LAN_KEY'),
'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'),
];
diff --git a/database/migrations/2026_05_20_183803_create_transactions_table.php b/database/migrations/2026_05_20_183803_create_transactions_table.php
new file mode 100644
index 0000000..9284f68
--- /dev/null
+++ b/database/migrations/2026_05_20_183803_create_transactions_table.php
@@ -0,0 +1,41 @@
+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');
+ }
+};
diff --git a/dev/fake-swish.php b/dev/fake-swish.php
new file mode 100644
index 0000000..a30e3d9
--- /dev/null
+++ b/dev/fake-swish.php
@@ -0,0 +1,129 @@
+ [
+ '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)))
+));
diff --git a/phpunit.xml b/phpunit.xml
index ab193d1..61c031c 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,6 +22,7 @@
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.
- +Fyll i deltagarens och dina uppgifter.