From b4125c1ee13b0fb9b36fabdb04d763aa4aa9e087 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 21 Sep 2026 13:39:40 +0200 Subject: [PATCH] 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// 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 --- README.md | 14 ++ lang/de/auth.php | 6 + lang/de/cart.php | 15 ++ lang/pl/auth.php | 6 + lang/pl/cart.php | 15 ++ .../Controllers/StripeWebhookController.php | 152 ++++++++++-- .../ShopServiceProvider/TranslationsTest.php | 79 ++++++ .../Unit/Stripe/StripeWebhookAddressTest.php | 226 ++++++++++++++++++ 8 files changed, 495 insertions(+), 18 deletions(-) create mode 100644 lang/de/auth.php create mode 100644 lang/de/cart.php create mode 100644 lang/pl/auth.php create mode 100644 lang/pl/cart.php create mode 100644 tests/Feature/ShopServiceProvider/TranslationsTest.php create mode 100644 tests/Unit/Stripe/StripeWebhookAddressTest.php diff --git a/README.md b/README.md index 78c28d5..9bf1a11 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,20 @@ $room = Product::create([ $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//.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 We test this package for many edge cases across every surface — products, diff --git a/lang/de/auth.php b/lang/de/auth.php new file mode 100644 index 0000000..985b856 --- /dev/null +++ b/lang/de/auth.php @@ -0,0 +1,6 @@ + 'Ungültige Zugangsdaten.', + 'login_ok' => 'Du bist angemeldet.', +]; diff --git a/lang/de/cart.php b/lang/de/cart.php new file mode 100644 index 0000000..1570751 --- /dev/null +++ b/lang/de/cart.php @@ -0,0 +1,15 @@ + '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.', +]; diff --git a/lang/pl/auth.php b/lang/pl/auth.php new file mode 100644 index 0000000..2e34d6f --- /dev/null +++ b/lang/pl/auth.php @@ -0,0 +1,6 @@ + 'Nieprawidłowe dane logowania.', + 'login_ok' => 'Jesteś zalogowany.', +]; diff --git a/lang/pl/cart.php b/lang/pl/cart.php new file mode 100644 index 0000000..94aa0a8 --- /dev/null +++ b/lang/pl/cart.php @@ -0,0 +1,15 @@ + '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.', +]; diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php index 0598f86..f43b0e7 100644 --- a/src/Http/Controllers/StripeWebhookController.php +++ b/src/Http/Controllers/StripeWebhookController.php @@ -10,6 +10,7 @@ use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Order; use Blax\Shop\Models\OrderNote; +use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPurchase; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -31,7 +32,7 @@ class StripeWebhookController */ public function handleWebhook(Request $request) { - if (!config('shop.stripe.enabled')) { + if (! config('shop.stripe.enabled')) { return response()->json(['error' => 'Stripe is not enabled'], 400); } @@ -50,9 +51,11 @@ class StripeWebhookController } } catch (\UnexpectedValueException $e) { Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]); + return response()->json(['error' => 'Invalid payload'], 400); } catch (SignatureVerificationException $e) { Log::error('Stripe webhook signature verification failed', ['error' => $e->getMessage()]); + return response()->json(['error' => 'Invalid signature'], 400); } @@ -106,6 +109,7 @@ class StripeWebhookController protected function handleUnknownEvent(string $type): bool { Log::info('Stripe webhook unhandled event type', ['type' => $type]); + return false; } @@ -116,14 +120,16 @@ class StripeWebhookController { $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]); + return false; } $cart = Cart::find($cartId); - if (!$cart) { + if (! $cart) { Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]); + return false; } @@ -145,7 +151,7 @@ class StripeWebhookController // Get or create order from the cart $order = $cart->order; - if (!$order) { + if (! $order) { // Create order from the converted 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 // Stripe provides amounts in cents, which matches our storage format $amountPaid = (int) ($session->amount_total ?? 0); @@ -167,7 +177,7 @@ class StripeWebhookController // Add a detailed note (customer-visible) $order->addNote( - "Payment of " . Order::formatMoney($amountPaid, $currency) . " received", + 'Payment of '.Order::formatMoney($amountPaid, $currency).' received', OrderNote::TYPE_PAYMENT, true ); @@ -187,6 +197,111 @@ class StripeWebhookController 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 */ @@ -194,8 +309,9 @@ class StripeWebhookController { $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]); + return false; } @@ -284,7 +400,7 @@ class StripeWebhookController // Try to find related order via 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); // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider) $order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe'); @@ -315,8 +431,8 @@ class StripeWebhookController $order = $this->findOrderByPaymentIntent($charge->payment_intent); if ($order) { $order->addNote( - 'Stripe charge failed: ' . ($charge->failure_message ?? 'Unknown error') . - ' (Charge: ' . $charge->id . ', Code: ' . ($charge->failure_code ?? 'none') . ')', + 'Stripe charge failed: '.($charge->failure_message ?? 'Unknown error'). + ' (Charge: '.$charge->id.', Code: '.($charge->failure_code ?? 'none').')', OrderNote::TYPE_PAYMENT ); } @@ -368,8 +484,8 @@ class StripeWebhookController $order->update(['status' => OrderStatus::ON_HOLD]); $disputeAmount = ($dispute->amount ?? 0) / 100; $order->addNote( - 'Payment dispute opened: ' . ($dispute->reason ?? 'Unknown reason') . - " (Dispute: {$dispute->id}, Amount: " . Order::formatMoney($disputeAmount, $order->currency) . ')', + 'Payment dispute opened: '.($dispute->reason ?? 'Unknown reason'). + " (Dispute: {$dispute->id}, Amount: ".Order::formatMoney($disputeAmount, $order->currency).')', OrderNote::TYPE_PAYMENT ); } @@ -494,7 +610,7 @@ class StripeWebhookController if ($order) { $refundAmount = (int) ($refund->amount / 100); // 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; @@ -539,7 +655,7 @@ class StripeWebhookController if ($order) { $amountPaid = ($invoice->amount_paid ?? 0) / 100; $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 ); } @@ -576,7 +692,7 @@ class StripeWebhookController */ protected function findOrderByPaymentIntent(?string $paymentIntentId): ?Order { - if (!$paymentIntentId) { + if (! $paymentIntentId) { return null; } @@ -606,7 +722,7 @@ class StripeWebhookController */ protected function findOrderByChargeId(?string $chargeId): ?Order { - if (!$chargeId) { + if (! $chargeId) { return null; } @@ -634,7 +750,7 @@ class StripeWebhookController $purchases = ProductPurchase::where('cart_id', $cart->id)->get(); foreach ($purchases as $purchase) { - if (!$purchase) { + if (! $purchase) { continue; } @@ -666,12 +782,12 @@ class StripeWebhookController protected function claimStockForPurchase(ProductPurchase $purchase) { $product = $purchase->purchasable; - if (!($product instanceof \Blax\Shop\Models\Product)) { + if (! ($product instanceof Product)) { return; } // Skip if product doesn't manage stock - if (!$product->manage_stock && !$product->isPool()) { + if (! $product->manage_stock && ! $product->isPool()) { return; } diff --git a/tests/Feature/ShopServiceProvider/TranslationsTest.php b/tests/Feature/ShopServiceProvider/TranslationsTest.php new file mode 100644 index 0000000..03b40d2 --- /dev/null +++ b/tests/Feature/ShopServiceProvider/TranslationsTest.php @@ -0,0 +1,79 @@ +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', " '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)); + } + } +} diff --git a/tests/Unit/Stripe/StripeWebhookAddressTest.php b/tests/Unit/Stripe/StripeWebhookAddressTest.php new file mode 100644 index 0000000..fce81f0 --- /dev/null +++ b/tests/Unit/Stripe/StripeWebhookAddressTest.php @@ -0,0 +1,226 @@ + 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); + } +}