mirror of
https://github.com/anna-sara/lan_kiosk
synced 2026-09-03 10:25:02 +02:00
Swish feature
This commit is contained in:
parent
b42be16c46
commit
df9f1cf17e
34 changed files with 2733 additions and 240 deletions
39
.env.example
39
.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=
|
||||
|
|
|
|||
24
app/Console/Commands/PruneStaleTransactions.php
Normal file
24
app/Console/Commands/PruneStaleTransactions.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneStaleTransactions extends Command
|
||||
{
|
||||
protected $signature = 'app:prune-stale-transactions';
|
||||
protected $description = 'Mark pending transactions older than 2 hours as expired';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// Marked rather than deleted: Swish retries a callback for longer than this,
|
||||
// and a payment whose row is gone can never be booked. An expired row is
|
||||
// still matched by the callback, and is there to reconcile against.
|
||||
$expired = Transaction::where('status', 'pending')
|
||||
->where('created_at', '<', now()->subHours(2))
|
||||
->update(['status' => 'expired']);
|
||||
|
||||
$this->info("Marked {$expired} stale transaction(s) as expired.");
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/SyncCustomers.php
Normal file
115
app/Console/Commands/SyncCustomers.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Tableversion;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncCustomers extends Command
|
||||
{
|
||||
protected $signature = 'app:sync-customers';
|
||||
protected $description = 'Sync participants and volunteers from the LAN API';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$client = new Client();
|
||||
|
||||
$versions = $this->fetchVersions($client);
|
||||
if (!$versions) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participantVersion = $this->ensureVersion('participants', $versions['participants']);
|
||||
$volunteerVersion = $this->ensureVersion('volunteers', $versions['volunteers']);
|
||||
|
||||
if (
|
||||
$participantVersion->version >= $versions['participants'] &&
|
||||
$volunteerVersion->version >= $versions['volunteers']
|
||||
) {
|
||||
$this->info('Already up to date.');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->fetchData($client);
|
||||
if (!$data) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data['participants'] as $participant) {
|
||||
Customer::updateOrCreate(
|
||||
['lan_id' => $participant['lan_id']],
|
||||
[
|
||||
'name' => $participant['first_name'] . ' ' . $participant['surname'],
|
||||
'guardian_name' => $participant['guardian_name'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($data['volunteers'] as $volunteer) {
|
||||
Customer::updateOrCreate(
|
||||
['lan_id' => $volunteer['lan_id']],
|
||||
[
|
||||
'name' => $volunteer['first_name'] . ' ' . $volunteer['surname'],
|
||||
'guardian_name' => $volunteer['first_name'] . ' ' . $volunteer['surname'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($participantVersion->version < $versions['participants']) {
|
||||
Tableversion::create(['table' => 'participants', 'version' => $participantVersion->version + 1]);
|
||||
$this->info('Participants synced.');
|
||||
}
|
||||
|
||||
if ($volunteerVersion->version < $versions['volunteers']) {
|
||||
Tableversion::create(['table' => 'volunteers', 'version' => $volunteerVersion->version + 1]);
|
||||
$this->info('Volunteers synced.');
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureVersion(string $table, int $latestVersion): Tableversion
|
||||
{
|
||||
$version = Tableversion::where('table', $table)->latest()->first();
|
||||
|
||||
if (!$version) {
|
||||
$version = Tableversion::create(['table' => $table, 'version' => $latestVersion - 1]);
|
||||
}
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
private function fetchVersions(Client $client): ?array
|
||||
{
|
||||
try {
|
||||
$response = $client->get(config('app.apilan_url') . 'version', $this->requestOptions());
|
||||
return json_decode((string) $response->getBody(), true);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to fetch versions: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchData(Client $client): ?array
|
||||
{
|
||||
try {
|
||||
$response = $client->get(config('app.apilan_url') . 'data', $this->requestOptions());
|
||||
return json_decode((string) $response->getBody(), true);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to fetch data: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function requestOptions(): array
|
||||
{
|
||||
return [
|
||||
'headers' => [
|
||||
'X-Api-Key' => config('app.apilan_key'),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'cert' => config('app.apilan_clientcert_path'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,15 +104,27 @@ class CustomerGroupController extends Controller
|
|||
$customers = $request->customers;
|
||||
$groupCustomer = Customer::where('customer_group_id', $id)->where('is_in_group', 0)->first();
|
||||
|
||||
if (!$groupCustomer) {
|
||||
return response()->json([
|
||||
'success' => false, 'message' => 'Customer group not found'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$movedAmount = 0;
|
||||
|
||||
foreach ($customers as $customerItem) {
|
||||
$customer = Customer::findOrFail($customerItem);
|
||||
$groupCustomer->deposit += $customer->deposit;
|
||||
$groupCustomer->amount_left += $customer->deposit;
|
||||
$movedAmount += Customer::findOrFail($customerItem)->joinGroup($id);
|
||||
}
|
||||
|
||||
$groupCustomer->deposit += $movedAmount;
|
||||
$groupCustomer->amount_left += $movedAmount;
|
||||
$groupCustomer->save();
|
||||
$customer->customer_group_id = $id;
|
||||
$customer->is_in_group = 1;
|
||||
$customer->deposit = 0;
|
||||
$customer->save();
|
||||
|
||||
if ($movedAmount > 0) {
|
||||
Deposit::create([
|
||||
'customer_id' => $groupCustomer->id,
|
||||
'amount' => $movedAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -132,8 +138,25 @@ 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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -42,45 +43,55 @@ class DepositController extends Controller
|
|||
$customer = Customer::where('lan_id',$request->customer_id)->first();
|
||||
}
|
||||
|
||||
if (!$customer) {
|
||||
return response()->json([
|
||||
'success' => false, 'message' => 'Hittade ingen deltagare'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// A group member pays into the group's balance
|
||||
if ($customer->is_in_group) {
|
||||
$account = Customer::where('customer_group_id', $customer->customer_group_id)->where('is_in_group', 0)->first();
|
||||
} else {
|
||||
$account = $customer;
|
||||
}
|
||||
|
||||
// Work out where the money goes before writing anything. Creating the
|
||||
// deposit first would leave a receipt behind for money never credited.
|
||||
if (!$account) {
|
||||
return response()->json([
|
||||
'success' => false, 'message' => 'Hittade inget konto att sätta in på'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$amount = (int) $request->deposit;
|
||||
|
||||
DB::transaction(function () use ($customer, $account, $amount, $request) {
|
||||
$account->deposit += $amount;
|
||||
$account->amount_left += $amount;
|
||||
$account->give_leftover = $request->give_leftover;
|
||||
$account->save();
|
||||
|
||||
// The balance lives on the group, so the member keeps none of it
|
||||
if ($account->id !== $customer->id) {
|
||||
$customer->deposit = 0;
|
||||
$customer->save();
|
||||
}
|
||||
|
||||
Deposit::create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => $request->deposit,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
|
||||
|
||||
|
||||
if ($customer->is_in_group ) {
|
||||
$groupCustomer = Customer::where('customer_group_id', $customer->customer_group_id)->where('is_in_group', 0)->first();
|
||||
$groupCustomer->deposit += $request->deposit;
|
||||
$groupCustomer->amount_left += $request->deposit;
|
||||
$groupCustomer->give_leftover = $request->give_leftover;
|
||||
$groupCustomer->save();
|
||||
$customer->deposit = 0;
|
||||
$customer->save();
|
||||
});
|
||||
|
||||
if ($request->manual_deposit === 1) {
|
||||
return redirect('customer/' . $request->customer_id);
|
||||
}
|
||||
|
||||
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'
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,15 +36,21 @@ 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'];
|
||||
$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([
|
||||
|
|
|
|||
379
app/Http/Controllers/SwishController.php
Normal file
379
app/Http/Controllers/SwishController.php
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Deposit;
|
||||
use App\Models\Transaction;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SwishController extends Controller
|
||||
{
|
||||
/** Text the payer sees in the Swish app. Max 50 characters. */
|
||||
private const PAYMENT_MESSAGE = 'vBytes LAN';
|
||||
|
||||
public function lookup(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['lan_id' => 'required|integer']);
|
||||
|
||||
$customer = Customer::where('lan_id', $request->lan_id)->first();
|
||||
|
||||
if (!$customer) {
|
||||
return response()->json(['message' => 'No customer found'], 404);
|
||||
}
|
||||
|
||||
$memberName = $customer->is_in_group ? $customer->name : null;
|
||||
$account = $this->accountFor($customer);
|
||||
|
||||
if (!$account) {
|
||||
return response()->json(['message' => 'No group found'], 404);
|
||||
}
|
||||
|
||||
// This endpoint has to be reachable without logging in, so it answers with
|
||||
// as little as the payer needs to recognise themselves. A group's name is
|
||||
// a label the group chose and is left as it is.
|
||||
$accountIsGroup = $account->customer_group_id !== null;
|
||||
|
||||
return response()->json([
|
||||
'customer_id' => $account->id,
|
||||
'name' => $accountIsGroup ? $account->name : $this->maskName($account->name),
|
||||
'member_name' => $memberName === null ? null : $this->maskName($memberName),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Erik Olsson" becomes "Erik O." — enough for the payer to see that they
|
||||
* typed the right LAN id, not enough to be worth harvesting.
|
||||
*/
|
||||
private function maskName(string $name): string
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$masked = [array_shift($parts)];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$masked[] = mb_strtoupper(mb_substr($part, 0, 1)) . '.';
|
||||
}
|
||||
|
||||
return implode(' ', $masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a payment lands on. For a group member that is the group's own
|
||||
* customer row, which is where the balance lives.
|
||||
*/
|
||||
private function accountFor(Customer $customer): ?Customer
|
||||
{
|
||||
if (!$customer->is_in_group) {
|
||||
return $customer;
|
||||
}
|
||||
|
||||
return Customer::where('customer_group_id', $customer->customer_group_id)
|
||||
->where('is_in_group', 0)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function initiate(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'customer_id' => 'required|integer|exists:customers,id',
|
||||
'amount' => 'required|integer|min:1',
|
||||
'give_leftover' => 'required|boolean',
|
||||
]);
|
||||
|
||||
// Allowed characters in payeePaymentReference are a-z A-Z 0-9 and -, max 35
|
||||
$ref = 'LAN-' . strtoupper(Str::random(8));
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'payment_reference' => $ref,
|
||||
'customer_id' => $request->customer_id,
|
||||
'expected_amount' => $request->amount,
|
||||
'status' => 'pending',
|
||||
'give_leftover' => $request->give_leftover,
|
||||
'source' => 'kiosk',
|
||||
'instruction_uuid' => strtoupper(str_replace('-', '', (string) Str::uuid())),
|
||||
// 32-36 characters, validated by Swish against ^[0-9a-zA-Z-]{32,36}$
|
||||
'callback_identifier' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$token = $this->createPaymentRequest($transaction);
|
||||
} catch (Throwable $e) {
|
||||
$transaction->status = 'failed';
|
||||
$transaction->save();
|
||||
|
||||
Log::error('Swish: could not create payment request', [
|
||||
'ref' => $ref,
|
||||
'error' => $e->getMessage(),
|
||||
// Swish answers validation errors with a body of error codes — log it,
|
||||
// it is the difference between a two-minute and a two-hour debug session
|
||||
'response' => $e instanceof RequestException && $e->hasResponse()
|
||||
? (string) $e->getResponse()->getBody()
|
||||
: null,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Kunde inte starta betalningen hos Swish.'], 502);
|
||||
}
|
||||
|
||||
// The token is what the Swish app opens. callbackurl only brings the payer back
|
||||
// here afterwards — the payment result is the callback Swish POSTs to our server.
|
||||
$swishUrl = 'swish://paymentrequest?token=' . $token
|
||||
. '&callbackurl=' . urlencode(url('/swish'));
|
||||
|
||||
return response()->json([
|
||||
'swish_url' => $swishUrl,
|
||||
'payment_reference' => $ref,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the payment with Swish and return the token the app switches on.
|
||||
*
|
||||
* The client certificate is what tells Swish which merchant is calling, and
|
||||
* is therefore also what makes the callback possible.
|
||||
*/
|
||||
private function createPaymentRequest(Transaction $transaction): string
|
||||
{
|
||||
$baseUrl = trim((string) config('app.swish_api_url'));
|
||||
|
||||
if ($baseUrl === '') {
|
||||
throw new RuntimeException('SWISH_API_URL is not set');
|
||||
}
|
||||
|
||||
$options = [
|
||||
'timeout' => 15,
|
||||
'connect_timeout' => 10,
|
||||
'json' => [
|
||||
'payeeAlias' => config('app.swish_payee_alias'),
|
||||
'currency' => 'SEK',
|
||||
'amount' => number_format((float) $transaction->expected_amount, 2, '.', ''),
|
||||
'callbackUrl' => rtrim((string) config('app.swish_callback_url'), '/') . '/kiosk',
|
||||
'payeePaymentReference' => $transaction->payment_reference,
|
||||
'message' => self::PAYMENT_MESSAGE,
|
||||
'callbackIdentifier' => $transaction->callback_identifier,
|
||||
],
|
||||
];
|
||||
|
||||
if ($cert = config('app.swish_clientcert_path')) {
|
||||
$options['cert'] = $cert;
|
||||
} else {
|
||||
Log::warning('Swish: no client certificate configured, the API will reject the call');
|
||||
}
|
||||
|
||||
if ($ca = config('app.swish_ca_path')) {
|
||||
$options['verify'] = $ca;
|
||||
}
|
||||
|
||||
// Resolved from the container so tests can swap in a fake Swish
|
||||
$response = app(Client::class)->request(
|
||||
'PUT',
|
||||
rtrim($baseUrl, '/') . '/swish-cpcapi/api/v2/paymentrequests/' . $transaction->instruction_uuid,
|
||||
$options
|
||||
);
|
||||
|
||||
// m-commerce hands back the app-switch token in a response header
|
||||
$token = $response->getHeaderLine('PaymentRequestToken');
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('Swish response contained no PaymentRequestToken header');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function callback(Request $request): Response
|
||||
{
|
||||
Log::info('Swish callback received', [
|
||||
'content_type' => $request->header('Content-Type'),
|
||||
'body' => $request->getContent(),
|
||||
'parsed' => $request->all(),
|
||||
]);
|
||||
|
||||
$ref = $this->paymentReference($request);
|
||||
$transaction = Transaction::where('payment_reference', $ref)->first();
|
||||
|
||||
if (!$transaction) {
|
||||
Log::warning('Swish: no transaction found for reference', ['ref' => $ref]);
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
// Anyone can reach this endpoint, so the callback has to prove it came from
|
||||
// Swish. callbackIdentifier is sent with the payment request and returned as
|
||||
// a header; it is never shared outside that channel.
|
||||
if (!$this->identifierMatches($request, $transaction)) {
|
||||
Log::warning('Swish: callbackIdentifier mismatch, callback rejected', ['ref' => $ref]);
|
||||
return response()->noContent(403);
|
||||
}
|
||||
|
||||
$status = (string) $request->input('status');
|
||||
|
||||
if ($status !== 'PAID') {
|
||||
// Record the outcome so a failed payment is not left looking pending
|
||||
if ($transaction->status !== 'PAID') {
|
||||
$transaction->status = $status !== '' ? $status : 'unknown';
|
||||
$transaction->save();
|
||||
}
|
||||
|
||||
Log::info('Swish: payment not completed', ['ref' => $ref, 'status' => $status]);
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
$paid = (float) $request->input('amount');
|
||||
$expected = $transaction->expected_amount;
|
||||
|
||||
// The amount is locked in the payment request, so it cannot legitimately
|
||||
// differ here. If it does, this is a bug or a callback we should not trust.
|
||||
if ($expected === null || abs($paid - (float) $expected) > 0.001) {
|
||||
$transaction->status = 'amount_mismatch';
|
||||
$transaction->save();
|
||||
|
||||
Log::error('Swish: paid amount does not match the payment request', [
|
||||
'ref' => $ref,
|
||||
'expected' => $expected,
|
||||
'paid' => $paid,
|
||||
]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
// Credit the amount we asked for, never a number taken from the callback
|
||||
$amount = (int) $expected;
|
||||
|
||||
$credited = $this->credit($transaction, $amount);
|
||||
|
||||
if (!$credited) {
|
||||
Log::info('Swish: callback did not result in a deposit', ['ref' => $ref]);
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
Log::info('Swish: payment processed', [
|
||||
'ref' => $ref,
|
||||
'customer_id' => $transaction->customer_id,
|
||||
'amount' => $amount,
|
||||
'give_leftover' => $transaction->give_leftover,
|
||||
]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the money onto the customer's balance.
|
||||
*
|
||||
* Swish resends a callback until it is answered, and two of them can arrive at
|
||||
* once. Everything runs in one database transaction with the rows locked, so a
|
||||
* repeat finds the payment already settled instead of crediting it twice.
|
||||
*/
|
||||
private function credit(Transaction $transaction, int $amount): bool
|
||||
{
|
||||
return DB::transaction(function () use ($transaction, $amount) {
|
||||
$locked = Transaction::whereKey($transaction->id)->lockForUpdate()->first();
|
||||
|
||||
if ($locked->status === 'PAID') {
|
||||
Log::info('Swish: duplicate callback ignored', ['ref' => $locked->payment_reference]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$customer = Customer::find($locked->customer_id);
|
||||
|
||||
if (!$customer) {
|
||||
Log::warning('Swish: no customer found for transaction', ['transaction_id' => $locked->id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// A group member pays into the group's balance, everyone else into their
|
||||
// own. lookup() normally resolves this before the transaction is created,
|
||||
// but a transaction made some other way may still point at the member.
|
||||
$account = $this->accountFor($customer);
|
||||
|
||||
if (!$account) {
|
||||
Log::warning('Swish: no group account found for customer', ['customer_id' => $customer->id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lock the row we are about to read-modify-write, so two payments into the
|
||||
// same account cannot overwrite each other's balance
|
||||
$account = Customer::whereKey($account->id)->lockForUpdate()->first();
|
||||
|
||||
$account->deposit += $amount;
|
||||
$account->amount_left += $amount;
|
||||
$account->give_leftover = $locked->give_leftover;
|
||||
$account->save();
|
||||
|
||||
if ($account->id !== $customer->id) {
|
||||
$customer->deposit = 0;
|
||||
$customer->save();
|
||||
}
|
||||
|
||||
Deposit::create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
|
||||
$locked->status = 'PAID';
|
||||
$locked->save();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A transaction without an identifier was never registered with Swish, so a
|
||||
* callback for it cannot be verified and is not trusted.
|
||||
*/
|
||||
private function identifierMatches(Request $request, Transaction $transaction): bool
|
||||
{
|
||||
if (!$transaction->callback_identifier) {
|
||||
Log::warning('Swish: transaction has no callbackIdentifier to verify against', [
|
||||
'ref' => $transaction->payment_reference,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($transaction->callback_identifier, (string) $request->header('callbackIdentifier'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a payment, polled by the payment page while the payer is in the
|
||||
* Swish app. The reference is random and only known to that payer.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['reference' => 'required|string']);
|
||||
|
||||
$transaction = Transaction::where('payment_reference', $request->reference)->first();
|
||||
|
||||
if (!$transaction) {
|
||||
return response()->json(['message' => 'Unknown reference'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $transaction->status,
|
||||
'amount' => $transaction->expected_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Our reference travels in payeePaymentReference. Older payments carried it in
|
||||
* message, sometimes behind a "kiosk|" prefix, so accept those too.
|
||||
*/
|
||||
private function paymentReference(Request $request): string
|
||||
{
|
||||
$ref = (string) ($request->input('payeePaymentReference') ?: $request->input('message'));
|
||||
|
||||
return Str::afterLast($ref, '|');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
24
app/Models/Transaction.php
Normal file
24
app/Models/Transaction.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'payment_reference',
|
||||
'customer_id',
|
||||
'expected_amount',
|
||||
'status',
|
||||
'give_leftover',
|
||||
'source',
|
||||
'instruction_uuid',
|
||||
'callback_identifier',
|
||||
];
|
||||
|
||||
/** Shared secret with Swish — never expose it in a response */
|
||||
protected $hidden = [
|
||||
'callback_identifier',
|
||||
];
|
||||
}
|
||||
|
|
@ -126,6 +126,11 @@ return [
|
|||
'apikey_deposit' => 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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('transactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Sent to Swish as payeePaymentReference and returned in the callback
|
||||
$table->string('payment_reference')->unique();
|
||||
$table->foreignId('customer_id');
|
||||
$table->integer('expected_amount')->nullable();
|
||||
$table->string('status')->default('pending');
|
||||
$table->boolean('give_leftover')->default(false);
|
||||
$table->string('source')->default('admin');
|
||||
// The UUID the payment request is created under in the Swish API,
|
||||
// needed to ask Swish about the payment afterwards
|
||||
$table->string('instruction_uuid')->nullable();
|
||||
// Sent with the payment request and returned unchanged as an HTTP header
|
||||
// in the callback. Never leaves the channel between us and Swish, so it
|
||||
// is what proves a callback really came from Swish.
|
||||
$table->string('callback_identifier')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('transactions');
|
||||
}
|
||||
};
|
||||
129
dev/fake-swish.php
Normal file
129
dev/fake-swish.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* A stand-in for the Swish Handel API, for trying the payment flow locally
|
||||
* before there is an agreement and a certificate.
|
||||
*
|
||||
* It answers the payment request the way Swish does — with a
|
||||
* PaymentRequestToken header — and then calls the callback URL from the request
|
||||
* a few seconds later, carrying the callbackIdentifier back as a header just
|
||||
* like the real one. Everything downstream is real: the receiver app, the
|
||||
* callback verification, the booking.
|
||||
*
|
||||
* The only thing it cannot do is open the Swish app. The token is made up, so
|
||||
* the app would reject it — on a phone the page simply stays in the waiting
|
||||
* state until the fake callback lands, which is exactly what we want to see.
|
||||
*
|
||||
* Start it inside the kiosk container:
|
||||
*
|
||||
* docker exec -d lan_kiosk-api-1 php -S 127.0.0.1:9099 /var/www/html/dev/fake-swish.php
|
||||
*
|
||||
* and point the app at it in .env:
|
||||
*
|
||||
* SWISH_API_URL=http://127.0.0.1:9099
|
||||
*
|
||||
* Amounts that behave differently, so every screen can be reached:
|
||||
*
|
||||
* 13 kr → DECLINED, the payer aborted in the app
|
||||
* 1 kr → no callback at all, the page keeps waiting until it times out
|
||||
* other → PAID after DELAY_SECONDS
|
||||
*
|
||||
* Delete this directory once Swish Handel is in place.
|
||||
*/
|
||||
|
||||
const DELAY_SECONDS = 4;
|
||||
|
||||
// Second mode: started in the background by the request below to deliver the
|
||||
// callback after the payment request has already been answered.
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$payload = json_decode(base64_decode($argv[1] ?? ''), true);
|
||||
|
||||
if (!$payload) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
sleep(DELAY_SECONDS);
|
||||
|
||||
$body = json_encode($payload['body']);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n"
|
||||
. 'callbackIdentifier: ' . $payload['identifier'] . "\r\n",
|
||||
'content' => $body,
|
||||
'timeout' => 15,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = @file_get_contents($payload['url'], false, $context);
|
||||
|
||||
file_put_contents('/tmp/fake-swish.log', sprintf(
|
||||
"[%s] callback %s -> %s\n",
|
||||
date('H:i:s'),
|
||||
$payload['url'],
|
||||
$result === false ? 'FAILED' : 'sent'
|
||||
), FILE_APPEND);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// First mode: the payment request itself
|
||||
$request = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$amount = (float) ($request['amount'] ?? 0);
|
||||
|
||||
file_put_contents('/tmp/fake-swish.log', sprintf(
|
||||
"[%s] %s %s %s\n",
|
||||
date('H:i:s'),
|
||||
$_SERVER['REQUEST_METHOD'],
|
||||
$_SERVER['REQUEST_URI'],
|
||||
json_encode($request)
|
||||
), FILE_APPEND);
|
||||
|
||||
// Swish rejects a payment request without a payee, and so do we
|
||||
if (empty($request['payeeAlias'])) {
|
||||
http_response_code(422);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([['errorCode' => 'PA02', 'errorMessage' => 'Payee alias is missing']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$instructionUuid = basename($_SERVER['REQUEST_URI']);
|
||||
|
||||
header('PaymentRequestToken: FAKE' . substr($instructionUuid, 0, 12));
|
||||
header('Location: http://127.0.0.1:9099/swish-cpcapi/api/v1/paymentrequests/' . $instructionUuid);
|
||||
http_response_code(201);
|
||||
|
||||
// 1 kr means "the payer never finishes", so no callback is ever sent
|
||||
if ((int) $amount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$callback = [
|
||||
// Already ends in /kiosk — the app puts it there so the receiver knows where to forward
|
||||
'url' => $request['callbackUrl'],
|
||||
'identifier' => $request['callbackIdentifier'] ?? '',
|
||||
'body' => [
|
||||
'id' => $instructionUuid,
|
||||
'payeePaymentReference' => $request['payeePaymentReference'] ?? '',
|
||||
'paymentReference' => strtoupper(bin2hex(random_bytes(16))),
|
||||
'payerAlias' => '46701234567',
|
||||
'payeeAlias' => $request['payeeAlias'],
|
||||
'amount' => $amount,
|
||||
'currency' => 'SEK',
|
||||
'message' => $request['message'] ?? '',
|
||||
'status' => (int) $amount === 13 ? 'DECLINED' : 'PAID',
|
||||
'errorCode' => (int) $amount === 13 ? 'BANKIDCL' : null,
|
||||
'errorMessage' => '',
|
||||
'dateCreated' => date('c'),
|
||||
'datePaid' => (int) $amount === 13 ? null : date('c'),
|
||||
],
|
||||
];
|
||||
|
||||
// Answer first, deliver the callback afterwards — the same order as the real thing
|
||||
exec(sprintf(
|
||||
'php %s %s > /dev/null 2>&1 &',
|
||||
escapeshellarg(__FILE__),
|
||||
escapeshellarg(base64_encode(json_encode($callback)))
|
||||
));
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,16 @@ export default function Authenticated({
|
|||
Grupper
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={route().current('settings')}
|
||||
>
|
||||
Inställningar
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="hidden sm:ms-6 sm:flex sm:items-center">
|
||||
<div className="relative ms-3">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Deltagare" />
|
||||
<Head title="Deltagare | vBytes LAN Kiosk" />
|
||||
|
||||
<section className='section'>
|
||||
<div className="container is-max-desktop">
|
||||
|
|
@ -174,10 +172,9 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
|
|||
</svg>
|
||||
</div>
|
||||
</details>
|
||||
{/*{groupmembers.length < 1 &&*/}
|
||||
<details className="box">
|
||||
<summary className='title is-4 my-3'>
|
||||
<span>Inbetalning Swish/kontant</span>
|
||||
<span>Inbetalning Kontant</span>
|
||||
<div className="summary-chevron-up">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="feather feather-chevron-down">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
|
|
@ -202,7 +199,8 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
|
|||
<label className="radio mr-3 mt-3">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customer.give_leftover === 1}
|
||||
className="accent-blue-600"
|
||||
checked={data.give_leftover === 1}
|
||||
onChange={() => setData('give_leftover', 1)}
|
||||
/>
|
||||
Ja
|
||||
|
|
@ -210,7 +208,8 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
|
|||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customer.give_leftover === 0}
|
||||
className="accent-blue-600"
|
||||
checked={data.give_leftover === 0}
|
||||
onChange={() => setData('give_leftover', 0)}
|
||||
/>
|
||||
Nej
|
||||
|
|
@ -230,7 +229,6 @@ export default function Customer({customer, groupmembers}: (CustomerProps & Grou
|
|||
</svg>
|
||||
</div>
|
||||
</details>
|
||||
{/*}*/}
|
||||
|
||||
|
||||
<details className="box">
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export default function CustomerGroups({groups, customers} :( CustomerGroupProps
|
|||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Dashboard" />
|
||||
<Head title="Grupper | vBytes LAN Kiosk" />
|
||||
{ addCustomerToGroupModal &&
|
||||
<div className='add-customer-to-group-modal'>
|
||||
<div className='add-customer-to-group-modal-content'>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default function Dashboard({ customers }: CustomerProps) {
|
|||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Dashboard" />
|
||||
<Head title="Dashboard | vBytes LAN Kiosk" />
|
||||
|
||||
<section className='section'>
|
||||
<div className="container is-max-desktop">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<Head title="Form" />
|
||||
<Head title="Form | vBytes LAN Kiosk" />
|
||||
|
||||
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
|
|
@ -29,11 +30,24 @@ export default function Form() {
|
|||
<div className="p-6 text-gray-900">
|
||||
<img className="form-logo" src="/img/logo.png" />
|
||||
<h1 className='title is-3 mb-2'>Registering av deltagare</h1>
|
||||
<p className="subtitle is-6">Fyll i deltagarens och dina uppgifter. Swisha sedan in en önskad summa pengar och meddela kioskpersonal eller betala in en önskad summa pengar i kontanter till kioskpersonal.</p>
|
||||
<p></p>
|
||||
<p className="subtitle is-6">Fyll i deltagarens och dina uppgifter.</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="field">
|
||||
<label className="label">Deltagarens förnamn och efternamn. (alt familjens efternamn om flera barn ska ha samma swishkonto)</label>
|
||||
<label className="label">LAN-ID</label>
|
||||
<div className="control">
|
||||
<TextInput
|
||||
required
|
||||
className="input"
|
||||
type="text"
|
||||
name="lan_id"
|
||||
value={data.lan_id}
|
||||
placeholder="LAN-ID"
|
||||
onChange={(e) => setData('lan_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Deltagarens förnamn och efternamn.</label>
|
||||
<div className="control">
|
||||
<TextInput
|
||||
required
|
||||
|
|
@ -80,16 +94,6 @@ export default function Form() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<label className="checkbox">
|
||||
<Checkbox type="checkbox" required/>
|
||||
<span> Jag godkänner att mina och deltagarens uppgifter används.</span>
|
||||
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field is-grouped">
|
||||
<div className="control">
|
||||
<button className="button is-link">Spara</button>
|
||||
|
|
|
|||
29
resources/js/Pages/Settings.tsx
Normal file
29
resources/js/Pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head} from '@inertiajs/react';
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
export default function Settings() {
|
||||
|
||||
const loadParticipants = (event: any) => {
|
||||
event.preventDefault()
|
||||
|
||||
fetch("/api/load_customers");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Inställningar | vBytes LAN Kiosk" />
|
||||
|
||||
<section className='section'>
|
||||
<div className="container is-max-desktop">
|
||||
<h1 className="title is-2">Inställningar</h1>
|
||||
<li className='cell button is-small is-white' onClick={(event) => loadParticipants(event)}>
|
||||
Hämta alla deltagare
|
||||
</li>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
291
resources/js/Pages/Swish.tsx
Normal file
291
resources/js/Pages/Swish.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Step = 'lookup' | 'pay' | 'waiting' | 'done';
|
||||
|
||||
// Swish only reports the outcome to our server, so the page asks us — not Swish —
|
||||
// how the payment went. Give up after a few minutes so it does not poll forever.
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export default function Swish() {
|
||||
const [step, setStep] = useState<Step>('lookup');
|
||||
const [lanId, setLanId] = useState('');
|
||||
const [customerId, setCustomerId] = useState<number | null>(null);
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [memberName, setMemberName] = useState<string | null>(null);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [giveLeftover, setGiveLeftover] = useState(false);
|
||||
const [reference, setReference] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const timers = useRef<number[]>([]);
|
||||
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
|
||||
// Stop polling when the page goes away
|
||||
useEffect(() => () => timers.current.forEach(clearTimeout), []);
|
||||
|
||||
const reset = () => {
|
||||
timers.current.forEach(clearTimeout);
|
||||
timers.current = [];
|
||||
setStep('lookup');
|
||||
setLanId('');
|
||||
setAmount('');
|
||||
setGiveLeftover(false);
|
||||
setMemberName(null);
|
||||
setReference(null);
|
||||
setStatus(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// Server errors carry a message worth showing — a network failure does not
|
||||
const messageFrom = (e: unknown, fallback: string) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||
return 'För många försök. Vänta en minut och prova igen.';
|
||||
}
|
||||
|
||||
return (axios.isAxiosError(e) && e.response?.data?.message) || fallback;
|
||||
};
|
||||
|
||||
const handleLookup = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/swish/lookup', { params: { lan_id: lanId } });
|
||||
setCustomerId(res.data.customer_id);
|
||||
setCustomerName(res.data.name);
|
||||
setMemberName(res.data.member_name ?? null);
|
||||
setStep('pay');
|
||||
} catch (e) {
|
||||
setError(messageFrom(e, 'Hittade ingen deltagare med det LAN-id:t.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollStatus = (ref: string, startedAt: number) => {
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/swish/status', { params: { reference: ref } });
|
||||
const current = res.data.status;
|
||||
|
||||
if (current === 'PAID' || current === 'amount_mismatch') {
|
||||
setStatus(current);
|
||||
setStep('done');
|
||||
return;
|
||||
}
|
||||
|
||||
if (['DECLINED', 'ERROR', 'CANCELLED', 'failed'].includes(current)) {
|
||||
setStatus(current);
|
||||
setStep('done');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// A failed poll is not a failed payment — keep trying until the timeout
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
pollStatus(ref, startedAt);
|
||||
} else {
|
||||
setStatus('timeout');
|
||||
setStep('done');
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
timers.current.push(timer);
|
||||
};
|
||||
|
||||
const handlePay = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.post('/api/swish/initiate', {
|
||||
customer_id: customerId,
|
||||
amount: parseInt(amount, 10),
|
||||
give_leftover: giveLeftover,
|
||||
});
|
||||
|
||||
setReference(res.data.payment_reference);
|
||||
setStep('waiting');
|
||||
pollStatus(res.data.payment_reference, Date.now());
|
||||
window.location.href = res.data.swish_url;
|
||||
} catch (e) {
|
||||
setError(messageFrom(e, 'Något gick fel. Försök igen.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const outcome = () => {
|
||||
if (status === 'PAID') {
|
||||
return {
|
||||
title: '✓ Betalningen är klar!',
|
||||
text: `${amount} kr är inlagt på ${memberName ? `gruppen ${customerName}` : 'ditt'} saldo.`,
|
||||
tone: 'has-text-success',
|
||||
};
|
||||
}
|
||||
if (status === 'amount_mismatch') {
|
||||
return {
|
||||
title: 'Beloppet stämmer inte',
|
||||
text: 'Betalningen kom fram men beloppet matchade inte. Prata med en funktionär.',
|
||||
tone: 'has-text-danger',
|
||||
};
|
||||
}
|
||||
if (status === 'timeout') {
|
||||
return {
|
||||
title: 'Vi väntar fortfarande',
|
||||
text: 'Betalningen har inte bekräftats än. Kolla ditt saldo om en stund, eller prata med en funktionär.',
|
||||
tone: 'has-text-grey',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Betalningen gick inte igenom',
|
||||
text: 'Inget har dragits. Du kan försöka igen.',
|
||||
tone: 'has-text-danger',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Betala in med Swish | vBytes LAN Kiosk" />
|
||||
<section className="section">
|
||||
<div className="container" style={{ maxWidth: 480 }}>
|
||||
<h1 className="title is-3 has-text-centered mb-6">Betala in med Swish</h1>
|
||||
|
||||
{step === 'lookup' && (
|
||||
<div className="box">
|
||||
<p>Ange ditt LAN-id för att hitta ditt konto.</p>
|
||||
<p className="mb-4"> Om du är med i en grupp kommer saldot skickas till din grupp.</p>
|
||||
{!isMobile && (
|
||||
<div className="notification is-warning is-light">
|
||||
Betalningen öppnas i Swish-appen, så den här sidan behöver köras i mobilen.
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleLookup}>
|
||||
<div className="field">
|
||||
<label className="label">LAN-id</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={lanId}
|
||||
onChange={e => setLanId(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="help is-danger mb-3">{error}</p>}
|
||||
<button
|
||||
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
|
||||
type="submit"
|
||||
>
|
||||
Hitta mig
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'pay' && (
|
||||
<div className="box">
|
||||
<div className="notification is-success is-light mb-6">
|
||||
<strong>{memberName ?? customerName}</strong> — stämmer det?
|
||||
{memberName && (
|
||||
<p className="mt-1">Ingår i gruppen <strong>{customerName}</strong></p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handlePay}>
|
||||
<div className="field">
|
||||
<label className="label">Belopp (kr)</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field mt-6 mb-6">
|
||||
<label className="label">Ge överblivet saldo till vBytes?</label>
|
||||
<div className="control">
|
||||
<label className="radio mr-4">
|
||||
<input
|
||||
type="radio"
|
||||
className="accent-blue-600 mr-1"
|
||||
checked={giveLeftover === true}
|
||||
onChange={() => setGiveLeftover(true)}
|
||||
/>
|
||||
Ja
|
||||
</label>
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
className="accent-blue-600 mr-1"
|
||||
checked={giveLeftover === false}
|
||||
onChange={() => setGiveLeftover(false)}
|
||||
/>
|
||||
Nej
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="help is-danger mb-3">{error}</p>}
|
||||
<div className="buttons mt-5">
|
||||
<button
|
||||
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
|
||||
type="submit"
|
||||
>
|
||||
Betala med Swish
|
||||
</button>
|
||||
<button
|
||||
className="button is-info is-outlined is-fullwidth"
|
||||
type="button"
|
||||
onClick={() => { setStep('lookup'); setMemberName(null); setError(''); }}
|
||||
>
|
||||
Fel person? Gå tillbaka
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'waiting' && (
|
||||
<div className="box has-text-centered">
|
||||
<p className="is-size-4 mb-4">Väntar på Swish…</p>
|
||||
<progress className="progress is-small is-link mb-5" max="100" />
|
||||
<p className="has-text-grey mb-2">
|
||||
Godkänn betalningen i Swish-appen. Den här sidan uppdaterar sig själv.
|
||||
</p>
|
||||
{reference && (
|
||||
<p className="is-size-7 has-text-grey-light">Referens: {reference}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<div className="box has-text-centered">
|
||||
<p className={`is-size-4 mb-4 ${outcome().tone}`}>{outcome().title}</p>
|
||||
<p className="has-text-grey mb-5">{outcome().text}</p>
|
||||
{reference && (
|
||||
<p className="is-size-7 has-text-grey-light mb-4">Referens: {reference}</p>
|
||||
)}
|
||||
<button className="button is-light" onClick={reset}>
|
||||
Gör en ny betalning
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function Thankyou() {
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head title="Tack" />
|
||||
<section className='section'>
|
||||
<div className="container is-max-desktop">
|
||||
<div className="box px-5 py-5">
|
||||
<h1 className="title px-3 py-3">Tack! Deltagaren är registrerad</h1>
|
||||
<p className="subtitle is-5 px-3 py-3">Swisha in en önskad summa pengar och meddela kioskpersonal eller betala in en önskad summa pengar i kontanter till kioskpersonal.</p>
|
||||
<a className='button' href="swish://">Öppna swish</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -1,104 +1,6 @@
|
|||
<?php
|
||||
|
||||
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\Customer;
|
||||
use App\Models\Tableversion;
|
||||
|
||||
Schedule::call(function () {
|
||||
|
||||
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
|
||||
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
|
||||
|
||||
$client = new Client();
|
||||
|
||||
$responseVersions= $client->request(
|
||||
'GET',
|
||||
config('app.apilan_url') . "version",
|
||||
[
|
||||
'headers'=> [
|
||||
'X-Api-Key' => config('app.apilan_key'),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json'
|
||||
],
|
||||
'cert' => config('app.apilan_clientcert_path')
|
||||
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
|
||||
],
|
||||
);
|
||||
$versions = json_decode((string) $responseVersions->getBody(), true);
|
||||
|
||||
if ($latestVersionParticipant === null ) {
|
||||
Tableversion::create([
|
||||
'table' => 'participants',
|
||||
'version' => $versions['participants'] - 1,
|
||||
]);
|
||||
$latestVersionParticipant = Tableversion::where('table', 'participants')->latest()->first();
|
||||
}
|
||||
|
||||
if ( $latestVersionVolunteer === null ) {
|
||||
Tableversion::create([
|
||||
'table' => 'volunteers',
|
||||
'version' => $versions['volunteers'] - 1,
|
||||
]);
|
||||
$latestVersionVolunteer = Tableversion::where('table', 'volunteers')->latest()->first();
|
||||
}
|
||||
|
||||
|
||||
if($latestVersionParticipant->version < $versions['participants'] || $latestVersionVolunteer->version < $versions['volunteers'] ) {
|
||||
$response = $client->request(
|
||||
'GET',
|
||||
config('app.apilan_url') . "data",
|
||||
[
|
||||
'headers'=> [
|
||||
'X-Api-Key' => config('app.apilan_key'),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json'
|
||||
],
|
||||
'cert' => config('app.apilan_clientcert_path')
|
||||
//'cert' => Storage::disk('public')->path('lan.vbytes.se.pem')
|
||||
],
|
||||
);
|
||||
$response_data = json_decode((string) $response->getBody(), true);
|
||||
|
||||
foreach ($response_data['participants'] as $participant) {
|
||||
Customer::updateOrCreate(
|
||||
['lan_id' => $participant['lan_id']],
|
||||
[
|
||||
'lan_id' => $participant['lan_id'],
|
||||
'name' => $participant['first_name'] . " " . $participant['surname'],
|
||||
'guardian_name' => $participant['guardian_name'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($response_data['volunteers'] as $volunteer) {
|
||||
Customer::updateOrCreate(
|
||||
['lan_id' => $volunteer['lan_id']],
|
||||
[
|
||||
'lan_id' => $volunteer['lan_id'],
|
||||
'name' => $volunteer['first_name'] . " " . $volunteer['surname'],
|
||||
'guardian_name' => $volunteer['first_name'] . " " . $volunteer['surname']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if($latestVersionParticipant->version < $versions['participants']) {
|
||||
Tableversion::create([
|
||||
'table' => 'participants',
|
||||
'version' =>$latestVersionParticipant->version + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
if($latestVersionVolunteer->version < $versions['volunteers']) {
|
||||
Tableversion::create([
|
||||
'table' => 'volunteers',
|
||||
'version' =>$latestVersionVolunteer->version + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
})->everyMinute();
|
||||
Schedule::command('app:sync-customers')->everyMinute();
|
||||
Schedule::command('app:prune-stale-transactions')->hourly();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
120
tests/Feature/BalanceReversalTest.php
Normal file
120
tests/Feature/BalanceReversalTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\DepositController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Deposit;
|
||||
use App\Models\Purchase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Deleting a deposit or a purchase has to undo exactly what creating it did.
|
||||
* Neither has a route today, so the controllers are called directly.
|
||||
*/
|
||||
class BalanceReversalTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Vårdnadshavare',
|
||||
'deposit' => 0,
|
||||
'amount_left' => 0,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function test_deleting_a_deposit_takes_the_money_back_off_the_balance(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
|
||||
$deposit = Deposit::create(['customer_id' => $customer->id, 'amount' => 200]);
|
||||
|
||||
(new DepositController())->destroy($deposit->id);
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(300, $customer->deposit);
|
||||
$this->assertSame(300, $customer->amount_left);
|
||||
$this->assertNull(Deposit::find($deposit->id));
|
||||
}
|
||||
|
||||
public function test_deleting_a_group_members_deposit_reverses_it_on_the_group_account(): void
|
||||
{
|
||||
$group = $this->customer([
|
||||
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
|
||||
'is_in_group' => 0, 'customer_group_id' => 7,
|
||||
]);
|
||||
$member = $this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
|
||||
'is_in_group' => 1, 'customer_group_id' => 7,
|
||||
]);
|
||||
|
||||
// Deposits from a member are recorded on the member but credited to the group
|
||||
$deposit = Deposit::create(['customer_id' => $member->id, 'amount' => 200]);
|
||||
|
||||
(new DepositController())->destroy($deposit->id);
|
||||
|
||||
$this->assertSame(300, $group->refresh()->deposit);
|
||||
$this->assertSame(300, $group->amount_left);
|
||||
$this->assertSame(0, $member->refresh()->deposit);
|
||||
}
|
||||
|
||||
public function test_deleting_a_purchase_gives_the_money_back(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 500, 'amount_left' => 300]);
|
||||
$purchase = Purchase::create(['customer_id' => $customer->id, 'amount' => 200]);
|
||||
|
||||
(new PurchaseController())->destroy($purchase->id);
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(500, $customer->amount_left);
|
||||
// A purchase never touched the deposit, so it stays where it was
|
||||
$this->assertSame(500, $customer->deposit);
|
||||
$this->assertNull(Purchase::find($purchase->id));
|
||||
}
|
||||
|
||||
public function test_a_deposit_survives_a_round_trip_through_store_and_destroy(): void
|
||||
{
|
||||
// lan_id deliberately differs from id, so it is clear which one store() used
|
||||
$customer = $this->customer(['lan_id' => 4242, 'deposit' => 500, 'amount_left' => 500]);
|
||||
|
||||
$request = \Illuminate\Http\Request::create('/api/register_deposit', 'POST', [
|
||||
'customer_id' => 4242,
|
||||
'deposit' => 250,
|
||||
'give_leftover' => 0,
|
||||
]);
|
||||
|
||||
(new DepositController())->store($request);
|
||||
|
||||
$this->assertSame(750, $customer->refresh()->deposit);
|
||||
|
||||
(new DepositController())->destroy(Deposit::first()->id);
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(500, $customer->deposit);
|
||||
$this->assertSame(500, $customer->amount_left);
|
||||
}
|
||||
|
||||
public function test_amount_left_can_be_mass_assigned_now_that_the_key_is_spelled_right(): void
|
||||
{
|
||||
$customer = Customer::create([
|
||||
'lan_id' => 5,
|
||||
'name' => 'Test Testsson',
|
||||
'guardian_name' => 'Vårdnadshavare',
|
||||
'deposit' => 100,
|
||||
'amount_left' => 100,
|
||||
]);
|
||||
|
||||
$this->assertSame(100, $customer->refresh()->amount_left);
|
||||
}
|
||||
}
|
||||
187
tests/Feature/CustomerGroupChangesTest.php
Normal file
187
tests/Feature/CustomerGroupChangesTest.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerGroup;
|
||||
use App\Models\Deposit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Adding someone to an existing group, and taking a group apart again.
|
||||
*/
|
||||
class CustomerGroupChangesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Karin Olsson',
|
||||
'deposit' => 0,
|
||||
'amount_left' => 0,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/** A group and its own customer row, the way store() leaves them */
|
||||
private function group(int $balance = 0): array
|
||||
{
|
||||
$group = CustomerGroup::create(['name' => 'Klanen']);
|
||||
|
||||
$groupCustomer = $this->customer([
|
||||
'lan_id' => 10,
|
||||
'name' => 'Klanen',
|
||||
'deposit' => $balance,
|
||||
'amount_left' => $balance,
|
||||
'is_in_group' => 0,
|
||||
'customer_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
return [$group, $groupCustomer];
|
||||
}
|
||||
|
||||
private function asUser(): self
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'sanctum');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function test_adding_a_member_moves_their_balance_to_the_group(): void
|
||||
{
|
||||
[$group, $groupCustomer] = $this->group(800);
|
||||
$anna = $this->customer(['lan_id' => 11, 'name' => 'Anna', 'deposit' => 300, 'amount_left' => 300]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(1100, $groupCustomer->refresh()->amount_left);
|
||||
$this->assertSame(1100, $groupCustomer->deposit);
|
||||
|
||||
$anna->refresh();
|
||||
$this->assertSame(1, (int) $anna->is_in_group);
|
||||
$this->assertSame($group->id, $anna->customer_group_id);
|
||||
}
|
||||
|
||||
public function test_a_member_who_has_spent_money_only_brings_what_is_left(): void
|
||||
{
|
||||
// Anna put in 300 and has spent 200, so 100 is hers to bring
|
||||
[$group, $groupCustomer] = $this->group(800);
|
||||
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 100]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(900, $groupCustomer->refresh()->amount_left);
|
||||
$this->assertSame(900, $groupCustomer->deposit);
|
||||
}
|
||||
|
||||
public function test_the_balance_is_cleared_on_the_member_so_it_is_not_counted_twice(): void
|
||||
{
|
||||
[$group] = $this->group(800);
|
||||
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id],
|
||||
]);
|
||||
|
||||
$anna->refresh();
|
||||
$this->assertSame(0, $anna->deposit);
|
||||
$this->assertSame(0, $anna->amount_left);
|
||||
}
|
||||
|
||||
public function test_the_moved_balance_is_recorded_as_a_deposit(): void
|
||||
{
|
||||
[$group, $groupCustomer] = $this->group(800);
|
||||
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id],
|
||||
]);
|
||||
|
||||
$this->assertSame(300, Deposit::where('customer_id', $groupCustomer->id)->sum('amount'));
|
||||
}
|
||||
|
||||
public function test_adding_several_members_at_once_adds_up(): void
|
||||
{
|
||||
[$group, $groupCustomer] = $this->group(0);
|
||||
$anna = $this->customer(['lan_id' => 11, 'deposit' => 300, 'amount_left' => 300]);
|
||||
$bo = $this->customer(['lan_id' => 12, 'deposit' => 200, 'amount_left' => 150]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id, $bo->id],
|
||||
]);
|
||||
|
||||
$this->assertSame(450, $groupCustomer->refresh()->amount_left);
|
||||
}
|
||||
|
||||
public function test_adding_to_a_group_without_an_account_row_gives_404(): void
|
||||
{
|
||||
$group = CustomerGroup::create(['name' => 'Tom grupp']);
|
||||
$anna = $this->customer(['lan_id' => 11]);
|
||||
|
||||
$this->asUser()->postJson('/api/add-to-customer-group/' . $group->id, [
|
||||
'customers' => [$anna->id],
|
||||
])->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_deleting_a_group_keeps_the_participants(): void
|
||||
{
|
||||
[$group, $groupCustomer] = $this->group(0);
|
||||
$anna = $this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$this->asUser()->deleteJson('/api/customer-group/' . $group->id)->assertOk();
|
||||
|
||||
// Anna is a real participant and has to survive the group being dissolved
|
||||
$anna->refresh();
|
||||
$this->assertNotNull($anna);
|
||||
$this->assertSame(0, (int) $anna->is_in_group);
|
||||
$this->assertNull($anna->customer_group_id);
|
||||
|
||||
// The group and the row that only existed to hold its balance are gone
|
||||
$this->assertNull(Customer::find($groupCustomer->id));
|
||||
$this->assertNull(CustomerGroup::find($group->id));
|
||||
}
|
||||
|
||||
public function test_a_group_with_money_left_is_not_deleted(): void
|
||||
{
|
||||
[$group, $groupCustomer] = $this->group(450);
|
||||
$anna = $this->customer([
|
||||
'lan_id' => 11, 'is_in_group' => 1, 'customer_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$this->asUser()->deleteJson('/api/customer-group/' . $group->id)
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertNotNull(CustomerGroup::find($group->id));
|
||||
$this->assertNotNull(Customer::find($groupCustomer->id));
|
||||
$this->assertSame(1, (int) $anna->refresh()->is_in_group);
|
||||
}
|
||||
|
||||
public function test_the_endpoints_are_closed_to_anyone_not_logged_in(): void
|
||||
{
|
||||
[$group] = $this->group(0);
|
||||
$anna = $this->customer(['lan_id' => 11]);
|
||||
|
||||
$this->postJson('/api/add-to-customer-group/' . $group->id, ['customers' => [$anna->id]])
|
||||
->assertStatus(401);
|
||||
|
||||
$this->deleteJson('/api/customer-group/' . $group->id)->assertStatus(401);
|
||||
|
||||
$this->assertNotNull(CustomerGroup::find($group->id));
|
||||
}
|
||||
}
|
||||
163
tests/Feature/CustomerGroupTest.php
Normal file
163
tests/Feature/CustomerGroupTest.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerGroup;
|
||||
use App\Models\Deposit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CustomerGroupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Karin Olsson',
|
||||
'deposit' => 0,
|
||||
'amount_left' => 0,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/** The group endpoints sit behind auth:sanctum */
|
||||
private function asUser(): self
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'sanctum');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function test_it_creates_a_group_with_a_customer_row_of_its_own(): void
|
||||
{
|
||||
$erik = $this->customer(['lan_id' => 1, 'name' => 'Erik', 'guardian_name' => 'Karin']);
|
||||
$anna = $this->customer(['lan_id' => 2, 'name' => 'Anna']);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id, $anna->id],
|
||||
])->assertRedirect('customer-groups/');
|
||||
|
||||
$group = CustomerGroup::firstWhere('name', 'Klanen');
|
||||
$this->assertNotNull($group);
|
||||
|
||||
// The group gets its own customer row, which is where the balance lives
|
||||
$groupCustomer = Customer::where('customer_group_id', $group->id)
|
||||
->where('is_in_group', 0)
|
||||
->first();
|
||||
|
||||
$this->assertSame('Klanen', $groupCustomer->name);
|
||||
// The guardian is taken from the first member in the list
|
||||
$this->assertSame('Karin', $groupCustomer->guardian_name);
|
||||
}
|
||||
|
||||
public function test_the_members_are_marked_as_belonging_to_the_group(): void
|
||||
{
|
||||
$erik = $this->customer(['lan_id' => 1, 'name' => 'Erik']);
|
||||
$anna = $this->customer(['lan_id' => 2, 'name' => 'Anna']);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id, $anna->id],
|
||||
]);
|
||||
|
||||
$group = CustomerGroup::firstWhere('name', 'Klanen');
|
||||
|
||||
foreach ([$erik, $anna] as $member) {
|
||||
$member->refresh();
|
||||
$this->assertSame(1, (int) $member->is_in_group);
|
||||
$this->assertSame($group->id, $member->customer_group_id);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_the_members_balances_are_moved_to_the_group(): void
|
||||
{
|
||||
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 500]);
|
||||
$anna = $this->customer(['lan_id' => 2, 'deposit' => 300, 'amount_left' => 300]);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id, $anna->id],
|
||||
]);
|
||||
|
||||
$group = CustomerGroup::firstWhere('name', 'Klanen');
|
||||
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
|
||||
|
||||
$this->assertSame(800, $groupCustomer->amount_left);
|
||||
$this->assertSame(800, $groupCustomer->deposit);
|
||||
|
||||
// Nothing is left behind on the members
|
||||
$this->assertSame(0, $erik->refresh()->amount_left);
|
||||
$this->assertSame(0, $erik->deposit);
|
||||
$this->assertSame(0, $anna->refresh()->amount_left);
|
||||
$this->assertSame(0, $anna->deposit);
|
||||
}
|
||||
|
||||
public function test_the_moved_balance_is_recorded_as_a_deposit_on_the_group(): void
|
||||
{
|
||||
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 500]);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id],
|
||||
]);
|
||||
|
||||
$group = CustomerGroup::firstWhere('name', 'Klanen');
|
||||
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
|
||||
|
||||
$this->assertSame(1, Deposit::where('customer_id', $groupCustomer->id)->count());
|
||||
$this->assertSame(500, Deposit::where('customer_id', $groupCustomer->id)->first()->amount);
|
||||
}
|
||||
|
||||
public function test_what_a_member_has_already_spent_does_not_follow_along(): void
|
||||
{
|
||||
// Erik put in 500 and has spent 200, so 300 is what he can still use
|
||||
$erik = $this->customer(['lan_id' => 1, 'deposit' => 500, 'amount_left' => 300]);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id],
|
||||
]);
|
||||
|
||||
$group = CustomerGroup::firstWhere('name', 'Klanen');
|
||||
$groupCustomer = Customer::where('customer_group_id', $group->id)->where('is_in_group', 0)->first();
|
||||
|
||||
// The group takes over what is left, and its deposit is set to the same
|
||||
// number — the 200 already spent is not carried over as history
|
||||
$this->assertSame(300, $groupCustomer->amount_left);
|
||||
$this->assertSame(300, $groupCustomer->deposit);
|
||||
}
|
||||
|
||||
public function test_it_requires_a_name_and_at_least_one_customer(): void
|
||||
{
|
||||
$this->asUser()->postJson('/api/customer-group', ['group_name' => 'Klanen'])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->asUser()->postJson('/api/customer-group', ['customers' => [1]])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame(0, CustomerGroup::count());
|
||||
}
|
||||
|
||||
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
|
||||
{
|
||||
$erik = $this->customer();
|
||||
|
||||
$this->postJson('/api/customer-group', [
|
||||
'group_name' => 'Klanen',
|
||||
'customers' => [$erik->id],
|
||||
])->assertStatus(401);
|
||||
|
||||
$this->assertSame(0, CustomerGroup::count());
|
||||
}
|
||||
}
|
||||
128
tests/Feature/DepositTest.php
Normal file
128
tests/Feature/DepositTest.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerGroup;
|
||||
use App\Models\Deposit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Registering a deposit over the counter.
|
||||
*/
|
||||
class DepositTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Karin Olsson',
|
||||
'deposit' => 0,
|
||||
'amount_left' => 0,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
private function asUser(): self
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'sanctum');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function test_a_deposit_is_credited_and_recorded(): void
|
||||
{
|
||||
$customer = $this->customer(['lan_id' => 42, 'deposit' => 100, 'amount_left' => 100]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_deposit', [
|
||||
'customer_id' => 42,
|
||||
'deposit' => 250,
|
||||
'give_leftover' => 1,
|
||||
])->assertOk();
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(350, $customer->deposit);
|
||||
$this->assertSame(350, $customer->amount_left);
|
||||
$this->assertSame(1, (int) $customer->give_leftover);
|
||||
$this->assertSame(250, Deposit::first()->amount);
|
||||
}
|
||||
|
||||
public function test_a_group_members_deposit_lands_on_the_group(): void
|
||||
{
|
||||
$group = CustomerGroup::create(['name' => 'Klanen']);
|
||||
|
||||
$groupCustomer = $this->customer([
|
||||
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
|
||||
'is_in_group' => 0, 'customer_group_id' => $group->id,
|
||||
]);
|
||||
$member = $this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_deposit', [
|
||||
'customer_id' => 11,
|
||||
'deposit' => 200,
|
||||
'give_leftover' => 0,
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(700, $groupCustomer->refresh()->deposit);
|
||||
$this->assertSame(700, $groupCustomer->amount_left);
|
||||
$this->assertSame(0, $member->refresh()->deposit);
|
||||
|
||||
// The receipt is kept on the person who paid, the balance on the group
|
||||
$this->assertSame($member->id, Deposit::first()->customer_id);
|
||||
}
|
||||
|
||||
public function test_an_unknown_customer_gives_404_and_writes_nothing(): void
|
||||
{
|
||||
$this->asUser()->postJson('/api/register_deposit', [
|
||||
'customer_id' => 999999,
|
||||
'deposit' => 250,
|
||||
'give_leftover' => 0,
|
||||
])->assertStatus(404);
|
||||
|
||||
$this->assertSame(0, Deposit::count());
|
||||
}
|
||||
|
||||
public function test_a_member_whose_group_row_is_missing_gives_404_and_writes_nothing(): void
|
||||
{
|
||||
// A broken state: the member points at a group that has no account row
|
||||
$member = $this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'is_in_group' => 1, 'customer_group_id' => 999,
|
||||
]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_deposit', [
|
||||
'customer_id' => 11,
|
||||
'deposit' => 200,
|
||||
'give_leftover' => 0,
|
||||
])->assertStatus(404);
|
||||
|
||||
// No receipt for money that was never credited anywhere
|
||||
$this->assertSame(0, Deposit::count());
|
||||
$this->assertSame(0, $member->refresh()->deposit);
|
||||
}
|
||||
|
||||
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
|
||||
{
|
||||
$customer = $this->customer(['lan_id' => 42]);
|
||||
|
||||
$this->postJson('/api/register_deposit', [
|
||||
'customer_id' => 42,
|
||||
'deposit' => 250,
|
||||
'give_leftover' => 0,
|
||||
])->assertStatus(401);
|
||||
|
||||
$this->assertSame(0, Deposit::count());
|
||||
$this->assertSame(0, $customer->refresh()->deposit);
|
||||
}
|
||||
}
|
||||
165
tests/Feature/PurchaseTest.php
Normal file
165
tests/Feature/PurchaseTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PurchaseTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Karin Olsson',
|
||||
'deposit' => 500,
|
||||
'amount_left' => 500,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
private function asUser(): self
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'sanctum');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function test_a_purchase_is_recorded_and_taken_off_the_balance(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 120,
|
||||
])->assertRedirect('customer/' . $customer->id);
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(380, $customer->amount_left);
|
||||
// What was paid in is history and does not change when something is bought
|
||||
$this->assertSame(500, $customer->deposit);
|
||||
|
||||
$purchase = Purchase::first();
|
||||
$this->assertSame($customer->id, $purchase->customer_id);
|
||||
$this->assertSame(120, $purchase->amount);
|
||||
}
|
||||
|
||||
public function test_several_purchases_add_up(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
|
||||
|
||||
foreach ([100, 50, 25] as $amount) {
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->assertSame(325, $customer->refresh()->amount_left);
|
||||
$this->assertSame(3, Purchase::count());
|
||||
}
|
||||
|
||||
public function test_the_purchase_shows_up_on_the_customer(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $customer->purchases()->count());
|
||||
$this->assertSame(120, $customer->purchases()->first()->amount);
|
||||
}
|
||||
|
||||
public function test_a_purchase_larger_than_the_balance_makes_it_negative(): void
|
||||
{
|
||||
// Nothing stops this today — the kiosk staff is trusted to check the balance
|
||||
$customer = $this->customer(['deposit' => 100, 'amount_left' => 100]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 250,
|
||||
]);
|
||||
|
||||
$this->assertSame(-150, $customer->refresh()->amount_left);
|
||||
}
|
||||
|
||||
public function test_a_group_purchase_is_taken_from_the_group_balance(): void
|
||||
{
|
||||
// Purchases are registered from the account page, so for a group it is the
|
||||
// group's own customer row that is charged
|
||||
$group = $this->customer([
|
||||
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 800, 'amount_left' => 800,
|
||||
'is_in_group' => 0, 'customer_group_id' => 7,
|
||||
]);
|
||||
$this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
|
||||
'is_in_group' => 1, 'customer_group_id' => 7,
|
||||
]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $group->id,
|
||||
'amount' => 200,
|
||||
]);
|
||||
|
||||
$this->assertSame(600, $group->refresh()->amount_left);
|
||||
}
|
||||
|
||||
public function test_it_requires_a_customer(): void
|
||||
{
|
||||
$this->asUser()->postJson('/api/register_purchase', ['amount' => 120])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame(0, Purchase::count());
|
||||
}
|
||||
|
||||
public function test_an_unknown_customer_gives_404_and_writes_nothing(): void
|
||||
{
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => 999999,
|
||||
'amount' => 120,
|
||||
])->assertStatus(404);
|
||||
|
||||
// No line item for a purchase that no balance ever paid for
|
||||
$this->assertSame(0, Purchase::count());
|
||||
}
|
||||
|
||||
public function test_the_purchase_and_the_deduction_are_written_together(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 500, 'amount_left' => 500]);
|
||||
|
||||
$this->asUser()->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
|
||||
// The balance always matches what the line items add up to
|
||||
$this->assertSame(1, Purchase::count());
|
||||
$this->assertSame(500 - (int) Purchase::sum('amount'), $customer->refresh()->amount_left);
|
||||
}
|
||||
|
||||
public function test_the_endpoint_is_closed_to_anyone_not_logged_in(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$this->postJson('/api/register_purchase', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 120,
|
||||
])->assertStatus(401);
|
||||
|
||||
$this->assertSame(500, $customer->refresh()->amount_left);
|
||||
$this->assertSame(0, Purchase::count());
|
||||
}
|
||||
}
|
||||
185
tests/Feature/SwishCallbackTest.php
Normal file
185
tests/Feature/SwishCallbackTest.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Deposit;
|
||||
use App\Models\Transaction;
|
||||
|
||||
/**
|
||||
* The callback is where money is created out of an HTTP request, so this is the
|
||||
* file to read first if something about the payment flow ever feels uncertain.
|
||||
*/
|
||||
class SwishCallbackTest extends SwishTestCase
|
||||
{
|
||||
public function test_a_paid_callback_credits_the_customer(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$customer->refresh();
|
||||
$this->assertSame(800, $customer->deposit);
|
||||
$this->assertSame(800, $customer->amount_left);
|
||||
$this->assertSame('PAID', Transaction::first()->status);
|
||||
$this->assertSame(1, Deposit::where('customer_id', $customer->id)->count());
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_callback_without_the_identifier(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id]);
|
||||
|
||||
$this->postCallback($this->paidPayload(), null)->assertStatus(403);
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame('pending', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_callback_with_the_wrong_identifier(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id]);
|
||||
|
||||
$this->postCallback($this->paidPayload(), 'ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||
->assertStatus(403);
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame('pending', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_callback_for_a_transaction_that_was_never_registered(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'callback_identifier' => null]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertStatus(403);
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
}
|
||||
|
||||
public function test_a_repeated_callback_only_credits_once(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$this->assertSame(800, $customer->refresh()->deposit);
|
||||
$this->assertSame(1, Deposit::count());
|
||||
}
|
||||
|
||||
public function test_it_refuses_an_amount_that_differs_from_the_payment_request(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload(['amount' => 5000]))->assertNoContent();
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame('amount_mismatch', Transaction::first()->status);
|
||||
$this->assertSame(0, Deposit::count());
|
||||
}
|
||||
|
||||
public function test_it_does_not_round_an_amount_with_ore(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload(['amount' => 99.50]))->assertNoContent();
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame('amount_mismatch', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_a_declined_payment_is_recorded_but_not_credited(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id]);
|
||||
|
||||
$this->postCallback($this->paidPayload([
|
||||
'status' => 'DECLINED',
|
||||
'errorCode' => 'BANKIDCL',
|
||||
]))->assertNoContent();
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame('DECLINED', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_an_unknown_reference_changes_nothing(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
|
||||
$this->postCallback($this->paidPayload(['payeePaymentReference' => 'LAN-NOSUCHREF']))
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertSame(700, $customer->refresh()->deposit);
|
||||
$this->assertSame(0, Deposit::count());
|
||||
}
|
||||
|
||||
public function test_a_group_member_payment_lands_on_the_group_account(): void
|
||||
{
|
||||
$group = $this->customer([
|
||||
'lan_id' => 10, 'name' => 'Klanen', 'deposit' => 500, 'amount_left' => 500,
|
||||
'is_in_group' => 0, 'customer_group_id' => 7,
|
||||
]);
|
||||
$member = $this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna', 'deposit' => 0, 'amount_left' => 0,
|
||||
'is_in_group' => 1, 'customer_group_id' => 7,
|
||||
]);
|
||||
|
||||
$this->transaction(['customer_id' => $member->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$this->assertSame(600, $group->refresh()->deposit);
|
||||
$this->assertSame(600, $group->amount_left);
|
||||
$this->assertSame(0, $member->refresh()->deposit);
|
||||
}
|
||||
|
||||
public function test_a_payment_to_a_customer_of_their_own_lands_on_themselves(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700, 'is_in_group' => 0]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$this->assertSame(800, $customer->refresh()->deposit);
|
||||
}
|
||||
|
||||
public function test_a_late_callback_on_an_expired_transaction_is_still_booked(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100, 'status' => 'expired']);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$this->assertSame(800, $customer->refresh()->deposit);
|
||||
$this->assertSame('PAID', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_the_leftover_choice_from_the_payment_follows_along(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 0, 'amount_left' => 0, 'give_leftover' => 0]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100, 'give_leftover' => 1]);
|
||||
|
||||
$this->postCallback($this->paidPayload())->assertNoContent();
|
||||
|
||||
$this->assertSame(1, (int) $customer->refresh()->give_leftover);
|
||||
}
|
||||
|
||||
public function test_it_still_understands_the_old_reference_in_the_message_field(): void
|
||||
{
|
||||
$customer = $this->customer(['deposit' => 700, 'amount_left' => 700]);
|
||||
$this->transaction(['customer_id' => $customer->id, 'expected_amount' => 100]);
|
||||
|
||||
$payload = $this->paidPayload(['message' => 'kiosk|LAN-TEST0001']);
|
||||
unset($payload['payeePaymentReference']);
|
||||
|
||||
$this->postCallback($payload)->assertNoContent();
|
||||
|
||||
$this->assertSame(800, $customer->refresh()->deposit);
|
||||
}
|
||||
}
|
||||
143
tests/Feature/SwishInitiateTest.php
Normal file
143
tests/Feature/SwishInitiateTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use GuzzleHttp\Psr7\Response as GuzzleResponse;
|
||||
|
||||
class SwishInitiateTest extends SwishTestCase
|
||||
{
|
||||
public function test_it_registers_the_payment_with_swish_and_returns_an_app_switch_url(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->fakeSwishAcceptsWithToken('TOKEN123');
|
||||
|
||||
$response = $this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 250,
|
||||
'give_leftover' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('swish://paymentrequest?token=TOKEN123', $response->json('swish_url'));
|
||||
|
||||
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
|
||||
$this->assertSame('pending', $transaction->status);
|
||||
$this->assertSame(250, $transaction->expected_amount);
|
||||
$this->assertNotEmpty($transaction->callback_identifier);
|
||||
}
|
||||
|
||||
public function test_the_request_to_swish_has_the_fields_swish_handel_requires(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->fakeSwishAcceptsWithToken();
|
||||
|
||||
$response = $this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 250,
|
||||
'give_leftover' => false,
|
||||
]);
|
||||
|
||||
$body = $this->lastSwishBody();
|
||||
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
|
||||
|
||||
$this->assertSame('1231181189', $body['payeeAlias']);
|
||||
$this->assertSame('SEK', $body['currency']);
|
||||
$this->assertSame('250.00', $body['amount']);
|
||||
$this->assertSame('https://swish.example.se/webhook/swish/kiosk', $body['callbackUrl']);
|
||||
$this->assertSame($transaction->payment_reference, $body['payeePaymentReference']);
|
||||
$this->assertSame($transaction->callback_identifier, $body['callbackIdentifier']);
|
||||
|
||||
// The reference has to survive Swish's own validation
|
||||
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9-]{1,35}$/', $body['payeePaymentReference']);
|
||||
$this->assertMatchesRegularExpression('/^[0-9a-zA-Z-]{32,36}$/', $body['callbackIdentifier']);
|
||||
$this->assertLessThanOrEqual(50, strlen($body['message']));
|
||||
}
|
||||
|
||||
public function test_the_payment_request_is_a_put_to_the_instruction_uuid(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->fakeSwishAcceptsWithToken();
|
||||
|
||||
$response = $this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 100,
|
||||
'give_leftover' => false,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::firstWhere('payment_reference', $response->json('payment_reference'));
|
||||
$request = end($this->swishRequests)['request'];
|
||||
|
||||
$this->assertSame('PUT', $request->getMethod());
|
||||
$this->assertSame(
|
||||
'/swish-cpcapi/api/v2/paymentrequests/' . $transaction->instruction_uuid,
|
||||
$request->getUri()->getPath()
|
||||
);
|
||||
$this->assertMatchesRegularExpression('/^[0-9A-F]{32}$/', $transaction->instruction_uuid);
|
||||
}
|
||||
|
||||
public function test_it_fails_cleanly_when_swish_is_not_configured(): void
|
||||
{
|
||||
config(['app.swish_api_url' => '']);
|
||||
$customer = $this->customer();
|
||||
|
||||
$response = $this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 100,
|
||||
'give_leftover' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(502);
|
||||
$this->assertSame('failed', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_it_fails_when_swish_rejects_the_payment_request(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->fakeSwish(new GuzzleResponse(422, [], json_encode([
|
||||
['errorCode' => 'PA02', 'errorMessage' => 'Amount value is missing or not a valid number'],
|
||||
])));
|
||||
|
||||
$response = $this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 100,
|
||||
'give_leftover' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(502);
|
||||
$this->assertSame('failed', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_it_fails_when_swish_answers_without_a_token(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->fakeSwish(new GuzzleResponse(201));
|
||||
|
||||
$this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 100,
|
||||
'give_leftover' => false,
|
||||
])->assertStatus(502);
|
||||
|
||||
$this->assertSame('failed', Transaction::first()->status);
|
||||
}
|
||||
|
||||
public function test_it_validates_its_input(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 0,
|
||||
'give_leftover' => false,
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->postJson('/api/swish/initiate', [
|
||||
'customer_id' => 999999,
|
||||
'amount' => 100,
|
||||
'give_leftover' => false,
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->assertSame(0, Transaction::count());
|
||||
}
|
||||
}
|
||||
114
tests/Feature/SwishLookupAndStatusTest.php
Normal file
114
tests/Feature/SwishLookupAndStatusTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Transaction;
|
||||
|
||||
class SwishLookupAndStatusTest extends SwishTestCase
|
||||
{
|
||||
public function test_it_finds_a_customer_by_lan_id(): void
|
||||
{
|
||||
$customer = $this->customer(['lan_id' => 42, 'name' => 'Erik Olsson']);
|
||||
|
||||
$this->getJson('/api/swish/lookup?lan_id=42')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'customer_id' => $customer->id,
|
||||
'name' => 'Erik O.',
|
||||
'member_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_the_endpoint_never_hands_out_a_full_name(): void
|
||||
{
|
||||
// Anyone can call this without logging in, and lan_id is easy to guess,
|
||||
// so a surname must not be readable in the answer
|
||||
$this->customer(['lan_id' => 42, 'name' => 'Erik Olsson']);
|
||||
|
||||
$response = $this->getJson('/api/swish/lookup?lan_id=42');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringNotContainsString('Olsson', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_a_name_with_several_surnames_is_masked_all_the_way(): void
|
||||
{
|
||||
$this->customer(['lan_id' => 42, 'name' => 'Anna Maria Svensson Berg']);
|
||||
|
||||
$this->getJson('/api/swish/lookup?lan_id=42')
|
||||
->assertOk()
|
||||
->assertJson(['name' => 'Anna M. S. B.']);
|
||||
}
|
||||
|
||||
public function test_a_group_member_is_resolved_to_the_group_account(): void
|
||||
{
|
||||
$group = $this->customer([
|
||||
'lan_id' => 10, 'name' => 'Klanen', 'is_in_group' => 0, 'customer_group_id' => 7,
|
||||
]);
|
||||
$this->customer([
|
||||
'lan_id' => 11, 'name' => 'Anna Svensson', 'is_in_group' => 1, 'customer_group_id' => 7,
|
||||
]);
|
||||
|
||||
// The payment goes to the group, but the page should greet Anna — and the
|
||||
// group's own name is a chosen label, so it is shown in full
|
||||
$this->getJson('/api/swish/lookup?lan_id=11')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'customer_id' => $group->id,
|
||||
'name' => 'Klanen',
|
||||
'member_name' => 'Anna S.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_an_unknown_lan_id_gives_404(): void
|
||||
{
|
||||
$this->getJson('/api/swish/lookup?lan_id=999')->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_a_member_without_a_group_account_gives_404(): void
|
||||
{
|
||||
$this->customer(['lan_id' => 11, 'is_in_group' => 1, 'customer_group_id' => 7]);
|
||||
|
||||
$this->getJson('/api/swish/lookup?lan_id=11')->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_the_status_endpoint_reports_the_payment(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->transaction(['customer_id' => $customer->id, 'status' => 'PAID', 'expected_amount' => 120]);
|
||||
|
||||
$this->getJson('/api/swish/status?reference=LAN-TEST0001')
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'PAID', 'amount' => 120]);
|
||||
}
|
||||
|
||||
public function test_the_status_endpoint_never_exposes_the_callback_identifier(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->transaction(['customer_id' => $customer->id]);
|
||||
|
||||
$response = $this->getJson('/api/swish/status?reference=LAN-TEST0001');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringNotContainsString('aaaaaaaa-bbbb', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_an_unknown_reference_gives_404(): void
|
||||
{
|
||||
$this->getJson('/api/swish/status?reference=LAN-NOSUCHREF')->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_stale_transactions_are_expired_rather_than_deleted(): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
$this->transaction(['customer_id' => $customer->id, 'payment_reference' => 'LAN-OLD00001'])
|
||||
->forceFill(['created_at' => now()->subHours(3)])->save();
|
||||
$this->transaction(['customer_id' => $customer->id, 'payment_reference' => 'LAN-NEW00001']);
|
||||
|
||||
$this->artisan('app:prune-stale-transactions')->assertSuccessful();
|
||||
|
||||
$this->assertSame('expired', Transaction::firstWhere('payment_reference', 'LAN-OLD00001')->status);
|
||||
$this->assertSame('pending', Transaction::firstWhere('payment_reference', 'LAN-NEW00001')->status);
|
||||
$this->assertSame(2, Transaction::count());
|
||||
}
|
||||
}
|
||||
122
tests/Feature/SwishTestCase.php
Normal file
122
tests/Feature/SwishTestCase.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Transaction;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Response as GuzzleResponse;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Shared setup for the Swish tests.
|
||||
*
|
||||
* Runs against an in-memory sqlite database (see phpunit.xml), so nothing here
|
||||
* can touch the real one, and no call ever leaves the machine.
|
||||
*/
|
||||
abstract class SwishTestCase extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Requests the fake Swish API received */
|
||||
protected array $swishRequests = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'app.swish_api_url' => 'https://mss.cpc.getswish.net',
|
||||
'app.swish_payee_alias' => '1231181189',
|
||||
'app.swish_callback_url' => 'https://swish.example.se/webhook/swish',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand in for the Swish API. Without a queued response the controller would
|
||||
* make a real call, so every test that reaches it has to set this up.
|
||||
*/
|
||||
protected function fakeSwish(GuzzleResponse $response): void
|
||||
{
|
||||
$stack = HandlerStack::create(new MockHandler([$response]));
|
||||
$stack->push(Middleware::history($this->swishRequests));
|
||||
|
||||
$this->app->instance(Client::class, new Client(['handler' => $stack]));
|
||||
}
|
||||
|
||||
protected function fakeSwishAcceptsWithToken(string $token = 'TOKEN123'): void
|
||||
{
|
||||
$this->fakeSwish(new GuzzleResponse(201, ['PaymentRequestToken' => $token]));
|
||||
}
|
||||
|
||||
/** The JSON body of the last request sent to Swish */
|
||||
protected function lastSwishBody(): array
|
||||
{
|
||||
$request = end($this->swishRequests)['request'];
|
||||
|
||||
return json_decode((string) $request->getBody(), true);
|
||||
}
|
||||
|
||||
protected function customer(array $attributes = []): Customer
|
||||
{
|
||||
$customer = new Customer();
|
||||
|
||||
// forceFill because the model's $fillable spells amount_left with a space
|
||||
$customer->forceFill(array_merge([
|
||||
'lan_id' => 1,
|
||||
'name' => 'Erik Olsson',
|
||||
'guardian_name' => 'Vårdnadshavare',
|
||||
'deposit' => 0,
|
||||
'amount_left' => 0,
|
||||
'give_leftover' => 0,
|
||||
'is_in_group' => 0,
|
||||
], $attributes))->save();
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
protected function transaction(array $attributes = []): Transaction
|
||||
{
|
||||
return Transaction::create(array_merge([
|
||||
'payment_reference' => 'LAN-TEST0001',
|
||||
'customer_id' => 1,
|
||||
'expected_amount' => 100,
|
||||
'status' => 'pending',
|
||||
'give_leftover' => 0,
|
||||
'source' => 'kiosk',
|
||||
'instruction_uuid' => 'AB23D7406ECE4542A80152D909EF9F6B',
|
||||
'callback_identifier' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a callback the way Swish would, with the identifier as a header.
|
||||
*/
|
||||
protected function postCallback(array $payload, ?string $identifier = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
{
|
||||
$headers = $identifier === null ? [] : ['callbackIdentifier' => $identifier];
|
||||
|
||||
return $this->postJson('/api/swish', $payload, $headers);
|
||||
}
|
||||
|
||||
protected function paidPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'id' => 'AB23D7406ECE4542A80152D909EF9F6B',
|
||||
'payeePaymentReference' => 'LAN-TEST0001',
|
||||
'paymentReference' => '6D6CD7406ECE4542A80152D909EF9F6B',
|
||||
'payerAlias' => '46701234567',
|
||||
'payeeAlias' => '1231181189',
|
||||
'amount' => 100,
|
||||
'currency' => 'SEK',
|
||||
'message' => 'vBytes LAN',
|
||||
'status' => 'PAID',
|
||||
'errorCode' => null,
|
||||
'errorMessage' => '',
|
||||
], $overrides);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue