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 @@ + diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 07dc2b8..5ec6f80 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -41,7 +41,16 @@ export default function Authenticated({ Grupper +
+ + Inställningar + +
+
diff --git a/resources/js/Pages/Customer.tsx b/resources/js/Pages/Customer.tsx index 05b5fc6..c13a4c4 100644 --- a/resources/js/Pages/Customer.tsx +++ b/resources/js/Pages/Customer.tsx @@ -22,7 +22,7 @@ interface CustomerProps { id: number amount: number }] - } + } }; interface GroupmembersProps { @@ -47,7 +47,7 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou id: customer.id, comment: "", 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)}) } - console.log( import.meta.env.API_KEY_DEPOSIT) - return ( - +
@@ -174,10 +172,9 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
- {/*{groupmembers.length < 1 &&*/} -
+
- Inbetalning Swish/kontant + Inbetalning Kontant
@@ -200,19 +197,21 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou

Ge överblivet saldo till vBytes:

@@ -230,7 +229,6 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
- {/*}*/}
diff --git a/resources/js/Pages/CustomerGroups.tsx b/resources/js/Pages/CustomerGroups.tsx index 3315c15..3d7d17c 100644 --- a/resources/js/Pages/CustomerGroups.tsx +++ b/resources/js/Pages/CustomerGroups.tsx @@ -202,7 +202,7 @@ export default function CustomerGroups({groups, customers} :( CustomerGroupProps return ( - + { addCustomerToGroupModal &&
diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index 1f02cc1..b6b0cc3 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -53,7 +53,7 @@ export default function Dashboard({ customers }: CustomerProps) { return ( - +
diff --git a/resources/js/Pages/Form.tsx b/resources/js/Pages/Form.tsx index 0741cf4..180307e 100644 --- a/resources/js/Pages/Form.tsx +++ b/resources/js/Pages/Form.tsx @@ -7,6 +7,7 @@ import { FormEventHandler } from 'react'; export default function Form() { const { data, setData, post, processing, errors, reset } = useForm({ + lan_id: '', name: '', guardian_name: '', give_leftover: false as boolean, @@ -21,7 +22,7 @@ export default function Form() { return (
- +
@@ -29,11 +30,24 @@ export default function Form() {

Registering av deltagare

-

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.

- + +
+ setData('lan_id', e.target.value)} + /> +
+
+
+
-
-
- -
-
-
diff --git a/resources/js/Pages/Settings.tsx b/resources/js/Pages/Settings.tsx new file mode 100644 index 0000000..4b0b7c4 --- /dev/null +++ b/resources/js/Pages/Settings.tsx @@ -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 ( + + + +
+
+

Inställningar

+
  • loadParticipants(event)}> + Hämta alla deltagare +
  • +
    +
    + +
    + ); +} diff --git a/resources/js/Pages/Swish.tsx b/resources/js/Pages/Swish.tsx new file mode 100644 index 0000000..889e36c --- /dev/null +++ b/resources/js/Pages/Swish.tsx @@ -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('lookup'); + const [lanId, setLanId] = useState(''); + const [customerId, setCustomerId] = useState(null); + const [customerName, setCustomerName] = useState(''); + const [memberName, setMemberName] = useState(null); + const [amount, setAmount] = useState(''); + const [giveLeftover, setGiveLeftover] = useState(false); + const [reference, setReference] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const timers = useRef([]); + + 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 ( + <> + +
    +
    +

    Betala in med Swish

    + + {step === 'lookup' && ( +
    +

    Ange ditt LAN-id för att hitta ditt konto.

    +

    Om du är med i en grupp kommer saldot skickas till din grupp.

    + {!isMobile && ( +
    + Betalningen öppnas i Swish-appen, så den här sidan behöver köras i mobilen. +
    + )} + +
    + +
    + setLanId(e.target.value)} + required + autoFocus + /> +
    +
    + {error &&

    {error}

    } + + +
    + )} + + {step === 'pay' && ( +
    +
    + {memberName ?? customerName} — stämmer det? + {memberName && ( +

    Ingår i gruppen {customerName}

    + )} +
    +
    +
    + +
    + setAmount(e.target.value)} + required + autoFocus + /> +
    +
    +
    + +
    + + +
    +
    + {error &&

    {error}

    } +
    + + +
    +
    +
    + )} + + {step === 'waiting' && ( +
    +

    Väntar på Swish…

    + +

    + Godkänn betalningen i Swish-appen. Den här sidan uppdaterar sig själv. +

    + {reference && ( +

    Referens: {reference}

    + )} +
    + )} + + {step === 'done' && ( +
    +

    {outcome().title}

    +

    {outcome().text}

    + {reference && ( +

    Referens: {reference}

    + )} + +
    + )} +
    +
    + + ); +} diff --git a/resources/js/Pages/Thankyou.tsx b/resources/js/Pages/Thankyou.tsx deleted file mode 100644 index 0c75dbd..0000000 --- a/resources/js/Pages/Thankyou.tsx +++ /dev/null @@ -1,22 +0,0 @@ - -import { Head } from '@inertiajs/react'; - -export default function Thankyou() { - - - return ( -
    - -
    -
    -
    -

    Tack! Deltagaren är registrerad

    -

    Swisha in en önskad summa pengar och meddela kioskpersonal eller betala in en önskad summa pengar i kontanter till kioskpersonal.

    - Öppna swish -
    -
    -
    - -
    - ); -} diff --git a/routes/api.php b/routes/api.php index 725dd63..cfad305 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,11 +6,12 @@ use App\Http\Controllers\CustomerController; use App\Http\Controllers\CustomerGroupController; use App\Http\Controllers\PurchaseController; use App\Http\Controllers\DepositController; +use App\Http\Controllers\SwishController; use App\Http\Middleware\ApiToken; -Route::post('register_customer', [CustomerController::class, 'store'])->name('register_customer'); 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('update_comment', [CustomerController::class, 'updateComment'])->name('update_comment'); 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::put('customer/{id}', [CustomerController::class, 'edit'])->name('edit_customer'); 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::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']); diff --git a/routes/console.php b/routes/console.php index 5536ff3..e08183a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,104 +1,6 @@ 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(); +Schedule::command('app:sync-customers')->everyMinute(); +Schedule::command('app:prune-stale-transactions')->hourly(); diff --git a/routes/web.php b/routes/web.php index c53c32c..8312453 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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-groups', [CustomerGroupController::class, 'index'])->middleware(['auth', 'verified'])->name('customer-groups'); -Route::get('/form', function () { +Route::get('/register_customer', function () { 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::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); diff --git a/tests/Feature/BalanceReversalTest.php b/tests/Feature/BalanceReversalTest.php new file mode 100644 index 0000000..dbf28fa --- /dev/null +++ b/tests/Feature/BalanceReversalTest.php @@ -0,0 +1,120 @@ +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); + } +} diff --git a/tests/Feature/CustomerGroupChangesTest.php b/tests/Feature/CustomerGroupChangesTest.php new file mode 100644 index 0000000..b21f977 --- /dev/null +++ b/tests/Feature/CustomerGroupChangesTest.php @@ -0,0 +1,187 @@ +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)); + } +} diff --git a/tests/Feature/CustomerGroupTest.php b/tests/Feature/CustomerGroupTest.php new file mode 100644 index 0000000..3e7a1e1 --- /dev/null +++ b/tests/Feature/CustomerGroupTest.php @@ -0,0 +1,163 @@ +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()); + } +} diff --git a/tests/Feature/DepositTest.php b/tests/Feature/DepositTest.php new file mode 100644 index 0000000..30a956e --- /dev/null +++ b/tests/Feature/DepositTest.php @@ -0,0 +1,128 @@ +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); + } +} diff --git a/tests/Feature/PurchaseTest.php b/tests/Feature/PurchaseTest.php new file mode 100644 index 0000000..a3a4495 --- /dev/null +++ b/tests/Feature/PurchaseTest.php @@ -0,0 +1,165 @@ +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()); + } +} diff --git a/tests/Feature/SwishCallbackTest.php b/tests/Feature/SwishCallbackTest.php new file mode 100644 index 0000000..492fad4 --- /dev/null +++ b/tests/Feature/SwishCallbackTest.php @@ -0,0 +1,185 @@ +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); + } +} diff --git a/tests/Feature/SwishInitiateTest.php b/tests/Feature/SwishInitiateTest.php new file mode 100644 index 0000000..891b57f --- /dev/null +++ b/tests/Feature/SwishInitiateTest.php @@ -0,0 +1,143 @@ +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()); + } +} diff --git a/tests/Feature/SwishLookupAndStatusTest.php b/tests/Feature/SwishLookupAndStatusTest.php new file mode 100644 index 0000000..18af085 --- /dev/null +++ b/tests/Feature/SwishLookupAndStatusTest.php @@ -0,0 +1,114 @@ +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()); + } +} diff --git a/tests/Feature/SwishTestCase.php b/tests/Feature/SwishTestCase.php new file mode 100644 index 0000000..7ddc8df --- /dev/null +++ b/tests/Feature/SwishTestCase.php @@ -0,0 +1,122 @@ + '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); + } +}