feat(i18n,stripe): de/pl translations + order address snapshots from Checkout Session
- lang/de and lang/pl for cart.php and auth.php (informal register), every key of lang/en translated; README documents the `shop::` namespace and the lang/vendor/shop/<locale>/ override path. - StripeWebhookController::handleCheckoutSessionCompleted now persists the session's customer_details as Order.billing_address and shipping_details as Order.shipping_address (falls back to billing when Stripe sent no shipping block). Snapshot shape: name, email, phone, line1, line2, postal_code, city, state, country, uid (first tax id). Only empty columns are filled; a session without either block leaves the order untouched. Optional host-added customer_email/customer_name columns are filled when present and empty. Accepts Stripe objects, stdClass and arrays. - Controller formatted with pint (composer lint). Tests: tests/Unit/Stripe/StripeWebhookAddressTest.php (5), tests/Feature/ShopServiceProvider/TranslationsTest.php (4). Full suite green (1477, 7 skipped). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
68d02b01fc
commit
b4125c1ee1
14
README.md
14
README.md
|
|
@ -184,6 +184,20 @@ $room = Product::create([
|
||||||
$isAvailable = $room->availableOnDate(now(), now()->addHour());
|
$isAvailable = $room->availableOnDate(now(), now()->addHour());
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
The package ships its user-facing messages under the `shop` translation
|
||||||
|
namespace (`lang/{en,de,pl}/cart.php` and `auth.php`, registered by
|
||||||
|
`ShopServiceProvider` via `loadTranslationsFrom(..., 'shop')`). Use them with
|
||||||
|
`__('shop::cart.added')`; the active `app()->getLocale()` picks the language
|
||||||
|
(`en`, `de`, `pl` shipped; other locales fall back to `en`).
|
||||||
|
|
||||||
|
To override or extend a message, drop a file at
|
||||||
|
`lang/vendor/shop/<locale>/<file>.php` in your application, e.g.
|
||||||
|
`lang/vendor/shop/de/cart.php` returning only the keys you want to change.
|
||||||
|
Laravel merges vendor overrides on top of the package defaults, so untouched
|
||||||
|
keys keep their shipped text.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
We test this package for many edge cases across every surface — products,
|
We test this package for many edge cases across every surface — products,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invalid_credentials' => 'Ungültige Zugangsdaten.',
|
||||||
|
'login_ok' => 'Du bist angemeldet.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_updated' => 'Warenkorb aktualisiert.',
|
||||||
|
'not_found' => 'Dieser Artikel wurde nicht gefunden.',
|
||||||
|
'item_not_found' => 'Dieser Warenkorb-Artikel wurde nicht gefunden.',
|
||||||
|
'out_of_stock' => 'Nicht genügend Bestand verfügbar.',
|
||||||
|
'add_failed' => 'Der Artikel konnte nicht in deinen Warenkorb gelegt werden.',
|
||||||
|
'added' => 'In den Warenkorb gelegt.',
|
||||||
|
'removed' => 'Aus dem Warenkorb entfernt.',
|
||||||
|
'converted' => 'Dieser Warenkorb wurde bereits bestellt.',
|
||||||
|
'checkout_failed' => 'Der Checkout konnte nicht gestartet werden.',
|
||||||
|
'checkout_unavailable' => 'Der Checkout ist derzeit nicht verfügbar.',
|
||||||
|
'checkout_created' => 'Du wirst zur Zahlung weitergeleitet.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invalid_credentials' => 'Nieprawidłowe dane logowania.',
|
||||||
|
'login_ok' => 'Jesteś zalogowany.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_updated' => 'Koszyk zaktualizowany.',
|
||||||
|
'not_found' => 'Nie znaleziono tego produktu.',
|
||||||
|
'item_not_found' => 'Nie znaleziono tej pozycji w koszyku.',
|
||||||
|
'out_of_stock' => 'Brak wystarczającej ilości na stanie.',
|
||||||
|
'add_failed' => 'Nie udało się dodać produktu do twojego koszyka.',
|
||||||
|
'added' => 'Dodano do koszyka.',
|
||||||
|
'removed' => 'Usunięto z koszyka.',
|
||||||
|
'converted' => 'Ten koszyk został już zamówiony.',
|
||||||
|
'checkout_failed' => 'Nie udało się rozpocząć płatności.',
|
||||||
|
'checkout_unavailable' => 'Płatność jest obecnie niedostępna.',
|
||||||
|
'checkout_created' => 'Przekierowujemy do płatności.',
|
||||||
|
];
|
||||||
|
|
@ -10,6 +10,7 @@ use Blax\Shop\Enums\PurchaseStatus;
|
||||||
use Blax\Shop\Models\Cart;
|
use Blax\Shop\Models\Cart;
|
||||||
use Blax\Shop\Models\Order;
|
use Blax\Shop\Models\Order;
|
||||||
use Blax\Shop\Models\OrderNote;
|
use Blax\Shop\Models\OrderNote;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -31,7 +32,7 @@ class StripeWebhookController
|
||||||
*/
|
*/
|
||||||
public function handleWebhook(Request $request)
|
public function handleWebhook(Request $request)
|
||||||
{
|
{
|
||||||
if (!config('shop.stripe.enabled')) {
|
if (! config('shop.stripe.enabled')) {
|
||||||
return response()->json(['error' => 'Stripe is not enabled'], 400);
|
return response()->json(['error' => 'Stripe is not enabled'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,9 +51,11 @@ class StripeWebhookController
|
||||||
}
|
}
|
||||||
} catch (\UnexpectedValueException $e) {
|
} catch (\UnexpectedValueException $e) {
|
||||||
Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]);
|
Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return response()->json(['error' => 'Invalid payload'], 400);
|
return response()->json(['error' => 'Invalid payload'], 400);
|
||||||
} catch (SignatureVerificationException $e) {
|
} catch (SignatureVerificationException $e) {
|
||||||
Log::error('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
|
Log::error('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return response()->json(['error' => 'Invalid signature'], 400);
|
return response()->json(['error' => 'Invalid signature'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +109,7 @@ class StripeWebhookController
|
||||||
protected function handleUnknownEvent(string $type): bool
|
protected function handleUnknownEvent(string $type): bool
|
||||||
{
|
{
|
||||||
Log::info('Stripe webhook unhandled event type', ['type' => $type]);
|
Log::info('Stripe webhook unhandled event type', ['type' => $type]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,14 +120,16 @@ class StripeWebhookController
|
||||||
{
|
{
|
||||||
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
||||||
|
|
||||||
if (!$cartId) {
|
if (! $cartId) {
|
||||||
Log::warning('Stripe checkout session completed without cart ID', ['session_id' => $session->id]);
|
Log::warning('Stripe checkout session completed without cart ID', ['session_id' => $session->id]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = Cart::find($cartId);
|
$cart = Cart::find($cartId);
|
||||||
if (!$cart) {
|
if (! $cart) {
|
||||||
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
|
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +151,7 @@ class StripeWebhookController
|
||||||
|
|
||||||
// Get or create order from the cart
|
// Get or create order from the cart
|
||||||
$order = $cart->order;
|
$order = $cart->order;
|
||||||
if (!$order) {
|
if (! $order) {
|
||||||
// Create order from the converted cart
|
// Create order from the converted cart
|
||||||
$order = Order::createFromCart($cart);
|
$order = Order::createFromCart($cart);
|
||||||
|
|
||||||
|
|
@ -157,6 +163,10 @@ class StripeWebhookController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the addresses Stripe collected during checkout so the
|
||||||
|
// order carries a buyer snapshot (invoicing, shipping labels, ...).
|
||||||
|
$this->persistSessionAddresses($order, $session);
|
||||||
|
|
||||||
// Record payment on the order
|
// Record payment on the order
|
||||||
// Stripe provides amounts in cents, which matches our storage format
|
// Stripe provides amounts in cents, which matches our storage format
|
||||||
$amountPaid = (int) ($session->amount_total ?? 0);
|
$amountPaid = (int) ($session->amount_total ?? 0);
|
||||||
|
|
@ -167,7 +177,7 @@ class StripeWebhookController
|
||||||
|
|
||||||
// Add a detailed note (customer-visible)
|
// Add a detailed note (customer-visible)
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
"Payment of " . Order::formatMoney($amountPaid, $currency) . " received",
|
'Payment of '.Order::formatMoney($amountPaid, $currency).' received',
|
||||||
OrderNote::TYPE_PAYMENT,
|
OrderNote::TYPE_PAYMENT,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
@ -187,6 +197,111 @@ class StripeWebhookController
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the Checkout Session's customer_details / shipping_details onto the
|
||||||
|
* order as address snapshots. Only empty columns are filled so an address
|
||||||
|
* the host application set beforehand is never overwritten. Null-safe:
|
||||||
|
* a session without either block leaves the order untouched.
|
||||||
|
*
|
||||||
|
* Snapshot shape (both columns): name, email, phone, line1, line2,
|
||||||
|
* postal_code, city, state, country, uid.
|
||||||
|
*/
|
||||||
|
protected function persistSessionAddresses(Order $order, $session): void
|
||||||
|
{
|
||||||
|
$customer = data_get($session, 'customer_details');
|
||||||
|
$shipping = data_get($session, 'shipping_details');
|
||||||
|
|
||||||
|
$billing = $this->addressSnapshotFromStripe($customer);
|
||||||
|
$shipped = $this->addressSnapshotFromStripe($shipping, $customer) ?? $billing;
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
if ($billing && empty((array) $order->billing_address)) {
|
||||||
|
$updates['billing_address'] = $billing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shipped && empty((array) $order->shipping_address)) {
|
||||||
|
$updates['shipping_address'] = $shipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional customer columns (not part of the shipped schema, but a host
|
||||||
|
// may add them): fill only when present and still empty.
|
||||||
|
$email = data_get($customer, 'email');
|
||||||
|
$name = data_get($customer, 'name');
|
||||||
|
foreach (['customer_email' => $email, 'customer_name' => $name] as $column => $value) {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $this->orderHasColumn($order, $column)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (empty($order->getAttribute($column))) {
|
||||||
|
$updates[$column] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updates === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->forceFill($updates)->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the JSON address snapshot from a Stripe details block
|
||||||
|
* (customer_details or shipping_details). Contact fields missing on the
|
||||||
|
* block (shipping_details carries no email/phone) are taken from
|
||||||
|
* $contactFallback. Returns null when the block is empty.
|
||||||
|
*/
|
||||||
|
protected function addressSnapshotFromStripe($details, $contactFallback = null): ?array
|
||||||
|
{
|
||||||
|
if (! $details) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = data_get($details, 'address');
|
||||||
|
$taxIds = data_get($details, 'tax_ids');
|
||||||
|
$uid = null;
|
||||||
|
if (is_iterable($taxIds)) {
|
||||||
|
foreach ($taxIds as $taxId) {
|
||||||
|
$uid = data_get($taxId, 'value');
|
||||||
|
if ($uid) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = [
|
||||||
|
'name' => data_get($details, 'name'),
|
||||||
|
'email' => data_get($details, 'email') ?? data_get($contactFallback, 'email'),
|
||||||
|
'phone' => data_get($details, 'phone') ?? data_get($contactFallback, 'phone'),
|
||||||
|
'line1' => data_get($address, 'line1'),
|
||||||
|
'line2' => data_get($address, 'line2'),
|
||||||
|
'postal_code' => data_get($address, 'postal_code'),
|
||||||
|
'city' => data_get($address, 'city'),
|
||||||
|
'state' => data_get($address, 'state'),
|
||||||
|
'country' => data_get($address, 'country'),
|
||||||
|
'uid' => $uid,
|
||||||
|
];
|
||||||
|
|
||||||
|
// A block with no usable data at all is treated as absent.
|
||||||
|
$hasData = array_filter($snapshot, fn ($v) => $v !== null && $v !== '');
|
||||||
|
|
||||||
|
return $hasData === [] ? null : $snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the orders table has an (optional, host-added) column.
|
||||||
|
*/
|
||||||
|
protected function orderHasColumn(Order $order, string $column): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $order->getConnection()->getSchemaBuilder()->hasColumn($order->getTable(), $column);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle checkout.session failed event
|
* Handle checkout.session failed event
|
||||||
*/
|
*/
|
||||||
|
|
@ -194,8 +309,9 @@ class StripeWebhookController
|
||||||
{
|
{
|
||||||
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
||||||
|
|
||||||
if (!$cartId) {
|
if (! $cartId) {
|
||||||
Log::warning('Stripe checkout session failed without cart ID', ['session_id' => $session->id]);
|
Log::warning('Stripe checkout session failed without cart ID', ['session_id' => $session->id]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +400,7 @@ class StripeWebhookController
|
||||||
|
|
||||||
// Try to find related order via payment_intent
|
// Try to find related order via payment_intent
|
||||||
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
||||||
if ($order && !$order->is_fully_paid) {
|
if ($order && ! $order->is_fully_paid) {
|
||||||
$amountPaid = (int) ($charge->amount / 100);
|
$amountPaid = (int) ($charge->amount / 100);
|
||||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
||||||
$order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe');
|
$order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe');
|
||||||
|
|
@ -315,8 +431,8 @@ class StripeWebhookController
|
||||||
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
'Stripe charge failed: ' . ($charge->failure_message ?? 'Unknown error') .
|
'Stripe charge failed: '.($charge->failure_message ?? 'Unknown error').
|
||||||
' (Charge: ' . $charge->id . ', Code: ' . ($charge->failure_code ?? 'none') . ')',
|
' (Charge: '.$charge->id.', Code: '.($charge->failure_code ?? 'none').')',
|
||||||
OrderNote::TYPE_PAYMENT
|
OrderNote::TYPE_PAYMENT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -368,8 +484,8 @@ class StripeWebhookController
|
||||||
$order->update(['status' => OrderStatus::ON_HOLD]);
|
$order->update(['status' => OrderStatus::ON_HOLD]);
|
||||||
$disputeAmount = ($dispute->amount ?? 0) / 100;
|
$disputeAmount = ($dispute->amount ?? 0) / 100;
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
'Payment dispute opened: ' . ($dispute->reason ?? 'Unknown reason') .
|
'Payment dispute opened: '.($dispute->reason ?? 'Unknown reason').
|
||||||
" (Dispute: {$dispute->id}, Amount: " . Order::formatMoney($disputeAmount, $order->currency) . ')',
|
" (Dispute: {$dispute->id}, Amount: ".Order::formatMoney($disputeAmount, $order->currency).')',
|
||||||
OrderNote::TYPE_PAYMENT
|
OrderNote::TYPE_PAYMENT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +610,7 @@ class StripeWebhookController
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$refundAmount = (int) ($refund->amount / 100);
|
$refundAmount = (int) ($refund->amount / 100);
|
||||||
// recordRefund(int $amount, ?string $reason)
|
// recordRefund(int $amount, ?string $reason)
|
||||||
$order->recordRefund($refundAmount, ($refund->reason ?? 'Refund created') . " (Refund: {$refund->id})");
|
$order->recordRefund($refundAmount, ($refund->reason ?? 'Refund created')." (Refund: {$refund->id})");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -539,7 +655,7 @@ class StripeWebhookController
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$amountPaid = ($invoice->amount_paid ?? 0) / 100;
|
$amountPaid = ($invoice->amount_paid ?? 0) / 100;
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
"Subscription invoice paid: " . Order::formatMoney($amountPaid, $order->currency) . " (Invoice: {$invoice->id})",
|
'Subscription invoice paid: '.Order::formatMoney($amountPaid, $order->currency)." (Invoice: {$invoice->id})",
|
||||||
OrderNote::TYPE_PAYMENT
|
OrderNote::TYPE_PAYMENT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -576,7 +692,7 @@ class StripeWebhookController
|
||||||
*/
|
*/
|
||||||
protected function findOrderByPaymentIntent(?string $paymentIntentId): ?Order
|
protected function findOrderByPaymentIntent(?string $paymentIntentId): ?Order
|
||||||
{
|
{
|
||||||
if (!$paymentIntentId) {
|
if (! $paymentIntentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -606,7 +722,7 @@ class StripeWebhookController
|
||||||
*/
|
*/
|
||||||
protected function findOrderByChargeId(?string $chargeId): ?Order
|
protected function findOrderByChargeId(?string $chargeId): ?Order
|
||||||
{
|
{
|
||||||
if (!$chargeId) {
|
if (! $chargeId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,7 +750,7 @@ class StripeWebhookController
|
||||||
$purchases = ProductPurchase::where('cart_id', $cart->id)->get();
|
$purchases = ProductPurchase::where('cart_id', $cart->id)->get();
|
||||||
|
|
||||||
foreach ($purchases as $purchase) {
|
foreach ($purchases as $purchase) {
|
||||||
if (!$purchase) {
|
if (! $purchase) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -666,12 +782,12 @@ class StripeWebhookController
|
||||||
protected function claimStockForPurchase(ProductPurchase $purchase)
|
protected function claimStockForPurchase(ProductPurchase $purchase)
|
||||||
{
|
{
|
||||||
$product = $purchase->purchasable;
|
$product = $purchase->purchasable;
|
||||||
if (!($product instanceof \Blax\Shop\Models\Product)) {
|
if (! ($product instanceof Product)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if product doesn't manage stock
|
// Skip if product doesn't manage stock
|
||||||
if (!$product->manage_stock && !$product->isPool()) {
|
if (! $product->manage_stock && ! $product->isPool()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\ShopServiceProvider;
|
||||||
|
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
class TranslationsTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function langDir(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 3).'/lang';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function shop_namespace_is_registered()
|
||||||
|
{
|
||||||
|
$this->assertEquals('Added to cart.', __('shop::cart.added'));
|
||||||
|
$this->assertEquals('Signed in.', __('shop::auth.login_ok'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function de_and_pl_translate_every_english_key()
|
||||||
|
{
|
||||||
|
foreach (['cart', 'auth'] as $file) {
|
||||||
|
$en = require $this->langDir()."/en/{$file}.php";
|
||||||
|
|
||||||
|
foreach (['de', 'pl'] as $locale) {
|
||||||
|
$path = $this->langDir()."/{$locale}/{$file}.php";
|
||||||
|
$this->assertFileExists($path);
|
||||||
|
$translated = require $path;
|
||||||
|
|
||||||
|
$this->assertEqualsCanonicalizing(
|
||||||
|
array_keys($en),
|
||||||
|
array_keys($translated),
|
||||||
|
"lang/{$locale}/{$file}.php must carry exactly the keys of lang/en/{$file}.php"
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($en as $key => $english) {
|
||||||
|
$this->assertNotSame('', trim((string) $translated[$key]), "{$locale}: {$file}.{$key} is empty");
|
||||||
|
$this->assertNotSame($english, $translated[$key], "{$locale}: {$file}.{$key} is still English");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function locale_switch_resolves_de_and_pl_messages()
|
||||||
|
{
|
||||||
|
app()->setLocale('de');
|
||||||
|
$this->assertEquals('In den Warenkorb gelegt.', __('shop::cart.added'));
|
||||||
|
|
||||||
|
app()->setLocale('pl');
|
||||||
|
$this->assertEquals('Dodano do koszyka.', __('shop::cart.added'));
|
||||||
|
|
||||||
|
app()->setLocale('fr');
|
||||||
|
$this->assertEquals('Added to cart.', __('shop::cart.added'), 'unknown locales fall back to en');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function apps_can_override_via_lang_vendor_shop()
|
||||||
|
{
|
||||||
|
$dir = $this->app->langPath('vendor/shop/de');
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
file_put_contents($dir.'/cart.php', "<?php\nreturn ['added' => 'Liegt im Korb.'];\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
app()->setLocale('de');
|
||||||
|
app('translator')->setLoaded([]);
|
||||||
|
|
||||||
|
$this->assertEquals('Liegt im Korb.', __('shop::cart.added'));
|
||||||
|
$this->assertEquals('Aus dem Warenkorb entfernt.', __('shop::cart.removed'), 'untouched keys keep the package text');
|
||||||
|
} finally {
|
||||||
|
@unlink($dir.'/cart.php');
|
||||||
|
@rmdir($dir);
|
||||||
|
@rmdir(dirname($dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||||
|
use Blax\Shop\Models\Order;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checkout.session.completed → Order.billing_address / shipping_address
|
||||||
|
* snapshots from the session's customer_details / shipping_details.
|
||||||
|
*/
|
||||||
|
class StripeWebhookAddressTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected StripeWebhookController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['shop.stripe.enabled' => true]);
|
||||||
|
$this->controller = new StripeWebhookController;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handle(object $session): bool
|
||||||
|
{
|
||||||
|
$method = (new ReflectionClass($this->controller))->getMethod('handleCheckoutSessionCompleted');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
return $method->invokeArgs($this->controller, [$session]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mockSession(array $overrides = []): object
|
||||||
|
{
|
||||||
|
return (object) array_merge([
|
||||||
|
'id' => 'cs_test_'.uniqid(),
|
||||||
|
'payment_intent' => 'pi_test_'.uniqid(),
|
||||||
|
'metadata' => (object) ['cart_id' => null],
|
||||||
|
'client_reference_id' => null,
|
||||||
|
'amount_total' => 10000,
|
||||||
|
'currency' => 'eur',
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
'customer' => 'cus_test_123',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function product(int $price = 10000): Product
|
||||||
|
{
|
||||||
|
return Product::factory()->withPrices(unit_amount: $price)->create(['manage_stock' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function stripeCustomerDetails(): object
|
||||||
|
{
|
||||||
|
return (object) [
|
||||||
|
'email' => 'anna@example.at',
|
||||||
|
'name' => 'Anna Beispiel',
|
||||||
|
'phone' => '+43 660 1234567',
|
||||||
|
'address' => (object) [
|
||||||
|
'line1' => 'Hauptstraße 1',
|
||||||
|
'line2' => 'Top 4',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Wien',
|
||||||
|
'state' => 'Wien',
|
||||||
|
'country' => 'AT',
|
||||||
|
],
|
||||||
|
'tax_ids' => [(object) ['type' => 'eu_vat', 'value' => 'ATU12345678']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function stripeShippingDetails(): object
|
||||||
|
{
|
||||||
|
return (object) [
|
||||||
|
'name' => 'Anna Beispiel (Büro)',
|
||||||
|
'address' => (object) [
|
||||||
|
'line1' => 'Lagergasse 9',
|
||||||
|
'line2' => null,
|
||||||
|
'postal_code' => '8010',
|
||||||
|
'city' => 'Graz',
|
||||||
|
'state' => 'Steiermark',
|
||||||
|
'country' => 'AT',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_persists_billing_and_shipping_snapshots()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$customer->addToCart($this->product());
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$session = $this->mockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'customer_details' => $this->stripeCustomerDetails(),
|
||||||
|
'shipping_details' => $this->stripeShippingDetails(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->handle($session));
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertNotNull($order);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'name' => 'Anna Beispiel',
|
||||||
|
'email' => 'anna@example.at',
|
||||||
|
'phone' => '+43 660 1234567',
|
||||||
|
'line1' => 'Hauptstraße 1',
|
||||||
|
'line2' => 'Top 4',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Wien',
|
||||||
|
'state' => 'Wien',
|
||||||
|
'country' => 'AT',
|
||||||
|
'uid' => 'ATU12345678',
|
||||||
|
], (array) $order->billing_address);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'name' => 'Anna Beispiel (Büro)',
|
||||||
|
'email' => 'anna@example.at',
|
||||||
|
'phone' => '+43 660 1234567',
|
||||||
|
'line1' => 'Lagergasse 9',
|
||||||
|
'line2' => null,
|
||||||
|
'postal_code' => '8010',
|
||||||
|
'city' => 'Graz',
|
||||||
|
'state' => 'Steiermark',
|
||||||
|
'country' => 'AT',
|
||||||
|
'uid' => null,
|
||||||
|
], (array) $order->shipping_address);
|
||||||
|
|
||||||
|
// Raw JSON in the DB carries the same shape
|
||||||
|
$raw = json_decode(DB::table($order->getTable())->where('id', $order->id)->value('billing_address'), true);
|
||||||
|
$this->assertSame('ATU12345678', $raw['uid']);
|
||||||
|
$this->assertSame('Wien', $raw['city']);
|
||||||
|
$this->assertSame(
|
||||||
|
['name', 'email', 'phone', 'line1', 'line2', 'postal_code', 'city', 'state', 'country', 'uid'],
|
||||||
|
array_keys($raw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function shipping_falls_back_to_billing_when_session_has_no_shipping_details()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$customer->addToCart($this->product());
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$this->handle($this->mockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'customer_details' => $this->stripeCustomerDetails(),
|
||||||
|
'shipping_details' => null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertEquals((array) $order->billing_address, (array) $order->shipping_address);
|
||||||
|
$this->assertSame('Hauptstraße 1', $order->shipping_address->line1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function session_without_customer_details_leaves_addresses_untouched()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$customer->addToCart($this->product());
|
||||||
|
$cart = $customer->checkoutCart();
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
|
||||||
|
$this->assertTrue($this->handle($this->mockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
])));
|
||||||
|
|
||||||
|
$order->refresh();
|
||||||
|
$this->assertNull($order->billing_address);
|
||||||
|
$this->assertNull($order->shipping_address);
|
||||||
|
$this->assertEquals(10000, $order->amount_paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function existing_addresses_are_not_overwritten()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$customer->addToCart($this->product());
|
||||||
|
$cart = $customer->checkoutCart();
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$order->update(['billing_address' => ['name' => 'Preset', 'line1' => 'Kept 1']]);
|
||||||
|
|
||||||
|
$this->handle($this->mockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'customer_details' => $this->stripeCustomerDetails(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$order->refresh();
|
||||||
|
$this->assertSame('Preset', $order->billing_address->name);
|
||||||
|
// shipping was empty, so it is filled from the session
|
||||||
|
$this->assertSame('Anna Beispiel', $order->shipping_address->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function array_shaped_session_details_are_accepted()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$customer->addToCart($this->product());
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$this->handle($this->mockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'customer_details' => [
|
||||||
|
'email' => 'jan@example.pl',
|
||||||
|
'name' => 'Jan Kowalski',
|
||||||
|
'phone' => null,
|
||||||
|
'address' => ['line1' => 'ul. Prosta 5', 'postal_code' => '00-001', 'city' => 'Warszawa', 'country' => 'PL'],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertSame('Jan Kowalski', $order->billing_address->name);
|
||||||
|
$this->assertSame('PL', $order->billing_address->country);
|
||||||
|
$this->assertNull($order->billing_address->line2);
|
||||||
|
$this->assertNull($order->billing_address->uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue