Use WooCommerce store currency for product and order money.
Deploy Ladill Woo Manager / deploy (push) Successful in 35s
Deploy Ladill Woo Manager / deploy (push) Successful in 35s
Sync currency from the plugin, format product prices and totals with it, and show the store currency on product price fields instead of hard-coding GHS.
This commit is contained in:
@@ -65,8 +65,13 @@ class WooOrder extends Model
|
|||||||
public function totalFormatted(): string
|
public function totalFormatted(): string
|
||||||
{
|
{
|
||||||
$minor = (int) $this->total_minor;
|
$minor = (int) $this->total_minor;
|
||||||
$currency = strtoupper((string) ($this->currency ?: 'GHS'));
|
$currency = strtoupper(trim((string) ($this->currency ?: '')));
|
||||||
|
if ($currency === '' && ($this->relationLoaded('store') || $this->store)) {
|
||||||
|
$currency = $this->store->currency();
|
||||||
|
}
|
||||||
|
|
||||||
return $currency.' '.number_format($minor / 100, 2);
|
$amount = number_format($minor / 100, 2);
|
||||||
|
|
||||||
|
return $currency !== '' ? $currency.' '.$amount : $amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,24 @@ class WooProduct extends Model
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
$currency = strtoupper((string) ($this->store?->metadata['currency'] ?? 'GHS'));
|
if ($this->relationLoaded('store') || $this->store) {
|
||||||
|
return $this->store->formatMoney($minor);
|
||||||
|
}
|
||||||
|
|
||||||
return $currency.' '.number_format($minor / 100, 2);
|
return number_format($minor / 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regularPriceFormatted(): string
|
||||||
|
{
|
||||||
|
if ($this->regular_price_minor === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->relationLoaded('store') || $this->store) {
|
||||||
|
return $this->store->formatMoney((int) $this->regular_price_minor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format(((int) $this->regular_price_minor) / 100, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function statusLabel(): string
|
public function statusLabel(): string
|
||||||
|
|||||||
@@ -70,4 +70,39 @@ class WooStore extends Model
|
|||||||
{
|
{
|
||||||
return rtrim((string) config('app.url'), '/').'/webhooks/woocommerce/'.$this->public_id;
|
return rtrim((string) config('app.url'), '/').'/webhooks/woocommerce/'.$this->public_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WooCommerce store currency (ISO 4217), synced from the connected site.
|
||||||
|
* Does not fall back to GHS — unknown currency is empty until the next sync.
|
||||||
|
*/
|
||||||
|
public function currency(): string
|
||||||
|
{
|
||||||
|
$meta = is_array($this->metadata) ? $this->metadata : [];
|
||||||
|
$currency = strtoupper(trim((string) ($meta['currency'] ?? '')));
|
||||||
|
|
||||||
|
return preg_match('/^[A-Z]{3}$/', $currency) === 1 ? $currency : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currencySymbol(): string
|
||||||
|
{
|
||||||
|
$meta = is_array($this->metadata) ? $this->metadata : [];
|
||||||
|
$symbol = trim((string) ($meta['currency_symbol'] ?? ''));
|
||||||
|
if ($symbol !== '') {
|
||||||
|
return $symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currency();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatMoney(int|float|null $minor): string
|
||||||
|
{
|
||||||
|
if ($minor === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$currency = $this->currency();
|
||||||
|
$amount = number_format(((int) $minor) / 100, 2);
|
||||||
|
|
||||||
|
return $currency !== '' ? $currency.' '.$amount : $amount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,53 @@ class CatalogSyncService
|
|||||||
|
|
||||||
public function syncAll(WooStore $store, ?User $user = null): array
|
public function syncAll(WooStore $store, ?User $user = null): array
|
||||||
{
|
{
|
||||||
|
$this->syncStoreMeta($store);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'categories' => $this->syncCategories($store),
|
'categories' => $this->syncCategories($store),
|
||||||
'products' => $this->syncProducts($store, $user),
|
'products' => $this->syncProducts($store, $user),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull store-level settings (currency, site name) from the WooCommerce plugin.
|
||||||
|
*/
|
||||||
|
public function syncStoreMeta(WooStore $store): void
|
||||||
|
{
|
||||||
|
$response = $this->client->get($store, 'store');
|
||||||
|
if (! is_array($response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyStoreMeta($store, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
public function applyStoreMeta(WooStore $store, array $payload): void
|
||||||
|
{
|
||||||
|
$currency = strtoupper(trim((string) ($payload['currency'] ?? '')));
|
||||||
|
if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = is_array($store->metadata) ? $store->metadata : [];
|
||||||
|
$meta['currency'] = $currency;
|
||||||
|
|
||||||
|
$symbol = trim((string) ($payload['currency_symbol'] ?? ''));
|
||||||
|
if ($symbol !== '') {
|
||||||
|
$meta['currency_symbol'] = $symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteName = trim((string) ($payload['site_name'] ?? ''));
|
||||||
|
$updates = ['metadata' => $meta];
|
||||||
|
if ($siteName !== '' && (string) $store->site_name !== $siteName) {
|
||||||
|
$updates['site_name'] = $siteName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->forceFill($updates)->save();
|
||||||
|
$store->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
public function syncCategories(WooStore $store): int
|
public function syncCategories(WooStore $store): int
|
||||||
{
|
{
|
||||||
$response = $this->client->get($store, 'categories', ['per_page' => 100]);
|
$response = $this->client->get($store, 'categories', ['per_page' => 100]);
|
||||||
@@ -53,11 +94,20 @@ class CatalogSyncService
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($response['currency']) || isset($response['currency_symbol'])) {
|
||||||
|
$this->applyStoreMeta($store, $response);
|
||||||
|
}
|
||||||
|
|
||||||
$items = $response['data'] ?? $response;
|
$items = $response['data'] ?? $response;
|
||||||
if (! is_array($items)) {
|
if (! is_array($items)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bare list responses are numeric arrays of products — ignore non-product keys.
|
||||||
|
if (array_is_list($items) === false && isset($response['data']) === false) {
|
||||||
|
$items = array_values(array_filter($items, fn ($item) => is_array($item) && isset($item['id'])));
|
||||||
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if (! is_array($item)) {
|
if (! is_array($item)) {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ class OrderIngestService
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
$totalMinor = (int) round(((float) ($payload['total'] ?? 0)) * 100);
|
$totalMinor = (int) round(((float) ($payload['total'] ?? 0)) * 100);
|
||||||
$currency = strtoupper((string) ($payload['currency'] ?? 'GHS'));
|
$currency = strtoupper(trim((string) ($payload['currency'] ?? '')));
|
||||||
|
if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
|
||||||
|
$currency = $store->currency();
|
||||||
|
}
|
||||||
|
if ($currency === '') {
|
||||||
|
$currency = 'XXX';
|
||||||
|
}
|
||||||
|
|
||||||
$wcUpdatedAt = isset($payload['date_modified_gmt'])
|
$wcUpdatedAt = isset($payload['date_modified_gmt'])
|
||||||
? Carbon::parse($payload['date_modified_gmt'].' UTC')
|
? Carbon::parse($payload['date_modified_gmt'].' UTC')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class OrderSyncService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private PluginClient $client,
|
private PluginClient $client,
|
||||||
private OrderIngestService $orders,
|
private OrderIngestService $orders,
|
||||||
|
private CatalogSyncService $catalog,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function syncOrders(WooStore $store): int
|
public function syncOrders(WooStore $store): int
|
||||||
@@ -18,11 +19,19 @@ class OrderSyncService
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($response['currency']) || isset($response['currency_symbol'])) {
|
||||||
|
$this->catalog->applyStoreMeta($store, $response);
|
||||||
|
}
|
||||||
|
|
||||||
$items = $response['data'] ?? $response;
|
$items = $response['data'] ?? $response;
|
||||||
if (! is_array($items)) {
|
if (! is_array($items)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_is_list($items) === false && isset($response['data']) === false) {
|
||||||
|
$items = array_values(array_filter($items, fn ($item) => is_array($item) && (isset($item['id']) || isset($item['number']))));
|
||||||
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if (! is_array($item)) {
|
if (! is_array($item)) {
|
||||||
|
|||||||
Binary file not shown.
@@ -40,16 +40,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$storeCurrency = $currentStore->currency();
|
||||||
|
$storeCurrencyLabel = $storeCurrency !== '' ? $storeCurrency : 'store currency';
|
||||||
|
@endphp
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Regular price</label>
|
<label class="block text-sm font-medium text-slate-700">Regular price ({{ $storeCurrencyLabel }})</label>
|
||||||
<input type="number" step="0.01" min="0" name="regular_price" value="{{ old('regular_price', $product->regular_price_minor !== null ? $product->regular_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
<div class="relative mt-1">
|
||||||
|
@if($storeCurrency !== '')
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-xs font-medium text-slate-400">{{ $storeCurrency }}</span>
|
||||||
|
@endif
|
||||||
|
<input type="number" step="0.01" min="0" name="regular_price"
|
||||||
|
value="{{ old('regular_price', $product->regular_price_minor !== null ? $product->regular_price_minor / 100 : '') }}"
|
||||||
|
class="w-full rounded-xl border-slate-200 text-sm {{ $storeCurrency !== '' ? 'pl-14' : '' }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Sale price</label>
|
<label class="block text-sm font-medium text-slate-700">Sale price ({{ $storeCurrencyLabel }})</label>
|
||||||
<input type="number" step="0.01" min="0" name="sale_price" value="{{ old('sale_price', $product->sale_price_minor !== null ? $product->sale_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
<div class="relative mt-1">
|
||||||
|
@if($storeCurrency !== '')
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-xs font-medium text-slate-400">{{ $storeCurrency }}</span>
|
||||||
|
@endif
|
||||||
|
<input type="number" step="0.01" min="0" name="sale_price"
|
||||||
|
value="{{ old('sale_price', $product->sale_price_minor !== null ? $product->sale_price_minor / 100 : '') }}"
|
||||||
|
class="w-full rounded-xl border-slate-200 text-sm {{ $storeCurrency !== '' ? 'pl-14' : '' }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if($storeCurrency === '')
|
||||||
|
<p class="mt-2 text-xs text-amber-700">Store currency not synced yet. Run <strong>Sync from WooCommerce</strong> on the products page so prices use your WooCommerce currency.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WooProduct;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\CatalogSyncService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WooStoreCurrencyTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_product_price_uses_store_woocommerce_currency_not_ghs_default(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$store = WooStore::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
'site_name' => 'Example Shop',
|
||||||
|
'status' => WooStore::STATUS_ACTIVE,
|
||||||
|
'metadata' => [
|
||||||
|
'currency' => 'USD',
|
||||||
|
'currency_symbol' => '$',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = WooProduct::query()->create([
|
||||||
|
'woo_store_id' => $store->id,
|
||||||
|
'external_id' => 42,
|
||||||
|
'name' => 'Notebook',
|
||||||
|
'slug' => 'notebook',
|
||||||
|
'status' => WooProduct::STATUS_PUBLISH,
|
||||||
|
'type' => 'simple',
|
||||||
|
'regular_price_minor' => 1999,
|
||||||
|
]);
|
||||||
|
$product->setRelation('store', $store);
|
||||||
|
|
||||||
|
$this->assertSame('USD', $store->currency());
|
||||||
|
$this->assertSame('USD 19.99', $product->priceFormatted());
|
||||||
|
$this->assertStringNotContainsString('GHS', $product->priceFormatted());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apply_store_meta_persists_woocommerce_currency(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$store = WooStore::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
'site_name' => 'Old name',
|
||||||
|
'status' => WooStore::STATUS_ACTIVE,
|
||||||
|
'metadata' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(CatalogSyncService::class)->applyStoreMeta($store, [
|
||||||
|
'currency' => 'eur',
|
||||||
|
'currency_symbol' => '€',
|
||||||
|
'site_name' => 'Euro Shop',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store->refresh();
|
||||||
|
$this->assertSame('EUR', $store->currency());
|
||||||
|
$this->assertSame('€', $store->currencySymbol());
|
||||||
|
$this->assertSame('Euro Shop', $store->site_name);
|
||||||
|
$this->assertSame('EUR 10.00', $store->formatMoney(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_currency_does_not_fall_back_to_ghs(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$store = WooStore::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => 'https://shop.example.com',
|
||||||
|
'status' => WooStore::STATUS_ACTIVE,
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('', $store->currency());
|
||||||
|
$this->assertSame('12.50', $store->formatMoney(1250));
|
||||||
|
$this->assertStringNotContainsString('GHS', $store->formatMoney(1250));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user