Merge branch 'master' into Privacy

This commit is contained in:
2026-09-16 00:13:41 +03:00
322 changed files with 21329 additions and 658 deletions
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
$table->string('carrier');
$table->string('tracking_reference')->unique();
$table->string('parent_reference')->nullable();
$table->timestamp('label_printed_at')->nullable();
$table->string('manifest_reference')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipments');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_info', function (Blueprint $table) {
$table->id();
$table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete();
$table->string('status');
$table->string('carrier_status')->nullable();
$table->text('message')->nullable();
$table->string('location')->nullable();
$table->timestamp('occurred_at');
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipment_info');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->string('type')->unique();
$table->boolean('enabled')->default(true);
$table->json('data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
* no ON DELETE clause, so deleting a Product with reviews throws a
* constraint violation instead of the review rows going with it — unlike
* every other Product-dependent table (variants, media, etc.), which does
* cascade. A review is dependent, disposable data, not something worth
* blocking a product deletion over.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products');
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* First-party copy of lunarphp/stripe's own create_stripe_payment_intents_table
* migration (package removed in favour of depending on stripe/stripe-php
* directly — see Modules\Core\Payment\Support\StripeManager and
* Modules\Core\Payment\Models\StripePaymentIntent, which replace the
* package's own classes over this same table). Timestamped to run just
* before this app's own add_context_to_stripe_payment_intents migration,
* which already alters this table.
*
* Guarded with hasTable(): on any environment that already ran
* lunarphp/stripe's own copy of this migration before the package was
* removed, the table already exists — this migration is only the one that
* actually creates it on a fresh install/database from now on.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable($this->prefix.'stripe_payment_intents')) {
return;
}
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
$table->string('intent_id')->index();
$table->string('status')->nullable();
$table->string('event_id')->index()->nullable();
$table->timestamp('processing_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* lunarphp/stripe's own stripe_payment_intents table already correlates a
* Stripe intent back to a cart/order via cart_id/order_id — exactly what
* Modules\Core\Payment\Drivers\StripePaymentDriver needs to recover
* $context in handleCallback(), a separate request (a webhook) from the
* pay()/authorize() call that originated it. Two columns this driver
* needs that the vendor table doesn't have:
* - context: the full opaque $context bag pay()/authorize() received,
* stored so handleCallback() can dispatch the SAME context the
* original call would have, without Payment inventing its own
* correlation table — see docs/payments.md "Async resolution".
* - payment_type: the payment type key (e.g. 'stripe') pay()/authorize()
* were called with — needed to dispatch Payment events with the
* correct $type in handleCallback(), which otherwise has no way to
* know it (a webhook payload doesn't carry it).
*
* Extends Lunar\Base\Migration (not the plain base Migration) so $this->prefix
* resolves the SAME table-prefix config every Lunar-owned table uses
* (config('lunar.database.table_prefix')) — the vendor migration that
* creates this table (lunarphp/stripe's create_stripe_payment_intents_table)
* already does this, so a store running with a non-default prefix (this
* one runs with 'lunar_') would otherwise have this migration fail against
* a table name that doesn't exist.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->json('context')->nullable()->after('status');
$table->string('payment_type')->nullable()->after('context');
});
}
public function down(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->dropColumn(['context', 'payment_type']);
});
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moves the driver mapping and per-type behavior that used to live in
* config('lunar.payments.types.{type}.*') onto the PaymentMethod row
* itself — same DB-instance-vs-config split Modules\Core\Shipping's own
* shipping_methods table already has (code/driver/name/enabled columns,
* no driver mapping in any config file). See docs/payments.md.
*
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* (NOT the same as `type` — two rows can share one driver).
* - name: admin-facing label. Nothing played this role before; `type`
* was always the machine slug.
* - capture_mode / captured_status / authorized_status: per-instance
* behavior — fails the "would a store ever want two different answers
* to this" cross-cutting-config test, so these move off config.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by the payment:sync-drivers command when
* `driver` no longer resolves via the registry — deliberately
* separate from `enabled`, so a driver vanishing (a deploy removed
* it) is never confused with an admin's own manual toggle, and a
* driver that comes back later auto-clears this with no admin action.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('name')->nullable()->after('type');
$table->string('driver')->nullable()->after('name');
$table->string('capture_mode')->nullable()->after('driver');
$table->string('captured_status')->nullable()->after('capture_mode');
$table->string('authorized_status')->nullable()->after('captured_status');
$table->unsignedInteger('position')->default(0)->after('authorized_status');
$table->timestamp('driver_missing_at')->nullable()->after('position');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn([
'name', 'driver', 'capture_mode', 'captured_status',
'authorized_status', 'position', 'driver_missing_at',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status (added in 2026_09_05_000001) cover a
* payment being taken, but nothing wrote Order.status on a REFUND —
* Order::paymentStatus() (Order\Support\OrderStatus::payment(), derived
* live from transactions) already reflects a refund correctly, but the
* stored status column — the one admin filtering, customer emails, etc.
* actually key off — never moved. Same reasoning as captured_status/
* authorized_status: a store could plausibly want a different resulting
* status per payment method (e.g. a "Refunded" vs. a "Refund Pending"
* variant), so this is a PaymentMethod column, not cross-cutting config.
*
* Deliberately no separate void_status — void never moved money (it
* releases an authorization hold before any capture), so it doesn't carry
* the same "the customer needs to see this changed" weight a refund does;
* add one later if a real need for it shows up.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('refunded_status')->nullable()->after('authorized_status');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn('refunded_status');
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Splits Lunar's single flat `status` column into three independently
* tracked axes — payment, fulfillment, return — so a payment refund and a
* fulfillment dispatch stop racing to write the same field, and each axis
* can be filtered/queried directly instead of overloading one string for
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
* where these columns actually get written. `status` itself is left in
* place, unchanged — Lunar core still reads/writes it in places this
* package doesn't own — but nothing in this package's business logic keys
* off it anymore after this migration's consumers land.
*
* lunar_customers already has a direct precedent for a boboko-core
* migration altering a Lunar-owned table (see
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
* a new pattern for this codebase, just the first time it's applied to
* lunar_orders.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('status')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Append-only audit trail for Order's three status axes (see
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
* is excluded from Lunar's own Spatie activity log), so this is a
* from-scratch mechanism, not a gap in an existing one.
*
* No `updated_at` — a row is never edited after it's written, only ever
* inserted. `event_class` is the FQCN of whatever business event/action
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
* markDispatchedAction' for a manual Filament action that has no backing
* event class of its own) — see Modules\Core\Order\Services\
* OrderStatusTransitionRecorder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('order_status_transitions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
$table->string('axis');
$table->string('from_status')->nullable();
$table->string('to_status');
$table->string('event_class');
$table->timestamp('created_at')->useCurrent();
$table->index(['order_id', 'axis']);
});
}
public function down(): void
{
Schema::dropIfExists('order_status_transitions');
}
};
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Maps every existing order's flat `status` (as it stood before
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
* payment_status/fulfillment_status/return_status columns. A separate
* migration from the schema change so the schema migration stays simply
* reversible via down(), and this data pass can be independently re-run.
*
* The flat status never captured refunds at all (no 'refunded' value was
* ever added to config('lunar.orders.statuses')), so the table-driven
* mapping below is corrected per-order by re-deriving
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
* unchanged derived-enum logic — and overriding payment_status to
* refunded/partially_refunded wherever it disagrees with the flat-status
* mapping. This is the one place the "keep the old derived enums" design
* decision earns its keep: refund-fraction math isn't reimplemented here,
* just reused.
*/
return new class extends Migration
{
private const MAP = [
'awaiting-payment' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
];
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$mapped = self::MAP[$order->status] ?? null;
if ($mapped === null) {
Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
'order_id' => $order->id,
'status' => $order->status,
]);
continue;
}
$paymentStatus = $mapped['payment_status'];
$derived = OrderStatus::payment($order);
if ($derived === PaymentStatus::Refunded) {
$paymentStatus = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$paymentStatus = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'payment_status' => $paymentStatus,
'fulfillment_status' => $mapped['fulfillment_status'],
'return_status' => 'none',
]);
}
});
}
public function down(): void
{
// Column defaults (set in the schema migration) are the correct
// "undo" — no need to reverse-map back to the flat status, since
// `status` itself was never touched by this migration.
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status/refunded_status let a merchant pick
* which per-method Order::status label a payment outcome resulted in — a
* mechanism that only made sense while Order.status was the single field
* carrying that meaning. Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
* there is no longer any per-method flexibility to preserve — "paid" is
* "paid" regardless of which method captured it. Dropped rather than left
* vestigial: keeping them visible in the admin would let a merchant
* configure something that silently does nothing.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('captured_status')->nullable();
$table->string('authorized_status')->nullable();
$table->string('refunded_status')->nullable();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Order::paid/paid_at — entirely independent of the `status` column (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing, especially for cash-on-delivery, cannot be modeled as a
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
* when it actually happened.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->boolean('paid')->default(false)->after('status')->index();
$table->timestamp('paid_at')->nullable()->after('paid');
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['paid', 'paid_at']);
});
}
};
@@ -0,0 +1,116 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
* model this session briefly built — abandoned before shipping — back
* onto a single `status` column plus the new independent `paid`/`paid_at`
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
* 2026_09_12_000003 (drops the axis columns this migration still reads).
*
* Priority rule: axis data where it's genuinely non-default (this order
* was really moved through the axis system during this session's manual
* testing); the legacy `status` column (which may still hold pre-session
* hyphenated values) as fallback everywhere else.
*/
return new class extends Migration
{
private const LEGACY_MAP = [
'awaiting-payment' => 'awaiting_payment',
'payment-offline' => 'awaiting_payment',
'payment-received' => 'processing',
'ready-for-dispatch' => 'ready_for_dispatch',
'ready-for-pickup' => 'ready_for_pickup',
'dispatched' => 'dispatched',
'completed' => 'completed',
];
/**
* Axis fulfillment_status -> new single status, given branch. Axis
* 'delivered' folds into 'return_window_open' (same combined-value
* decision the going-forward design makes). Axis payment_status is
* used only to decide whether a fully-unfulfilled order should read
* as 'awaiting_payment' or 'processing'.
*/
private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
{
if ($return === 'returned') {
return 'returned';
}
if ($return === 'requested') {
return 'return_requested';
}
return match ($fulfillment) {
'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
'processing' => 'processing',
'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
'in_transit' => 'dispatched',
'delivered', 'return_window_open' => 'return_window_open',
'picked_up' => 'picked_up',
'completed' => 'completed',
default => null,
};
}
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$isPickup = $order->isStorePickupOrder();
$axisIsDefault = $order->payment_status === 'awaiting_payment'
&& $order->fulfillment_status === 'unfulfilled'
&& $order->return_status === 'none';
$status = $axisIsDefault
? (self::LEGACY_MAP[$order->status] ?? null)
: $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
if ($status === null) {
Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
'order_id' => $order->id,
'status' => $order->status,
'payment_status' => $order->payment_status,
'fulfillment_status' => $order->fulfillment_status,
'return_status' => $order->return_status,
]);
$status = 'awaiting_payment';
}
$derived = OrderStatus::payment($order);
$paid = $order->payment_status === 'paid'
|| in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
// A refund implies the order concluded via a return —
// even one backfilled to an early status (e.g. an order
// refunded before fulfillment ever started) is corrected
// to refunded/partially_refunded here, not left stuck
// pre-fulfillment with no sign a refund ever happened.
if ($derived === PaymentStatus::Refunded) {
$status = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$status = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'status' => $status,
'paid' => $paid,
'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
]);
}
});
}
public function down(): void
{
// No reverse mapping — column defaults (post-rollback of the
// schema migrations) are the correct "undo".
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
* 3-axis model was abandoned before shipping in favor of a single
* `status` column plus independent `paid`/`paid_at` (see
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
* still reads these columns for the backfill.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
public function down(): void
{
// Mirrors 2026_09_11_000001's own down() — restores columns
// empty/defaulted, does not attempt to resurrect real per-order
// values.
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* There is only one status column left to audit (plus the synthetic
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
* so the `axis` column this table was created with
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
* means anything.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id', 'axis']);
$table->dropColumn('axis');
$table->index('order_id');
});
}
public function down(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id']);
$table->string('axis')->default('status')->after('order_id');
$table->index(['order_id', 'axis']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The seeded 'cash-on-delivery' PaymentMethod row
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
* wired to driver => 'offline' — the same immediate-capture driver as
* cash-in-hand. That's the bug that made COD "pay immediately" instead of
* waiting for staff to confirm cash was actually received. Repoints
* already-seeded environments to the new dedicated
* Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
* itself is fixed separately for fresh installs.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
}
public function down(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 'return_window_open' is renamed to 'delivered' — same status value,
* same meaning (the parcel arrived AND the return window is now open,
* still one combined moment — see Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
* on the order page rather than an internal mechanic. Also renames it in
* order_status_transitions' audit rows so the history stays consistent
* with `status` going forward.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('lunar_orders')->where('status', 'return_window_open')->update(['status' => 'delivered']);
DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
}
public function down(): void
{
DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real record of "a manifest was issued", not just a loose
* manifest_reference string stamped onto each Shipment row — ACS's own
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
* so this table is entirely our own bookkeeping: when the manifest was
* issued and how many shipments it included, not something re-derivable
* from the carrier later. `shipment_count` is denormalized (also
* countable via shipments()->count()) purely so the manifests list can
* render without an extra query per row.
*
* carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
* SupportsManifestBatching, the same contract any future carrier
* (Speedex, etc.) implements to get manifest batching at all; this table
* has no ACS-specific columns.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('manifests', function (Blueprint $table) {
$table->id();
$table->string('carrier');
$table->string('reference');
$table->unsignedInteger('shipment_count')->default(0);
$table->timestamp('issued_at');
$table->timestamps();
$table->unique(['carrier', 'reference']);
});
}
public function down(): void
{
Schema::dropIfExists('manifests');
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Replaces the loose manifest_reference string with a real manifests
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
* one Manifest row per distinct (carrier, manifest_reference) pair
* already present in shipments, using the earliest label_printed_at (or
* updated_at as a fallback) among that group as a best-effort issued_at,
* since the exact original issue time was never recorded anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
});
$groups = DB::table('shipments')
->select('carrier', 'manifest_reference')
->whereNotNull('manifest_reference')
->distinct()
->get();
foreach ($groups as $group) {
$shipments = DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->get();
$issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
?? $shipments->pluck('updated_at')->min();
$manifestId = DB::table('manifests')->insertGetId([
'carrier' => $group->carrier,
'reference' => $group->manifest_reference,
'shipment_count' => $shipments->count(),
'issued_at' => $issuedAt,
'created_at' => $issuedAt,
'updated_at' => $issuedAt,
]);
DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->update(['manifest_id' => $manifestId]);
}
Schema::table('shipments', function (Blueprint $table) {
$table->dropColumn('manifest_reference');
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->string('manifest_reference')->nullable()->after('parent_reference');
});
DB::table('shipments')
->whereNotNull('manifest_id')
->orderBy('id')
->each(function ($shipment) {
$manifest = DB::table('manifests')->find($shipment->manifest_id);
if ($manifest) {
DB::table('shipments')->where('id', $shipment->id)->update([
'manifest_reference' => $manifest->reference,
]);
}
});
Schema::table('shipments', function (Blueprint $table) {
$table->dropConstrainedForeignId('manifest_id');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Caps brute-forcing a 6-digit OTP code (1M combinations, 10-minute
* window, previously uncapped) — see Modules\Core\Auth\Services\
* UserOtpService::validate(), which now invalidates the code entirely
* (forcing a fresh generateAndSend()) once otp_attempts reaches its max,
* rather than leaving a live code guessable indefinitely within its
* expiry window.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_attempts');
});
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A per-login session registry, independent of the actual session store
* driver (SESSION_DRIVER=redis in this app — no "sessions" table to
* purge by user_id the way the database driver would allow). Each
* successful OTP login (Modules\Core\Auth\Services\UserOtpService::
* validate()) records one row here and stamps the token into the
* Laravel session payload; Modules\Core\Auth\Http\Middleware\
* EnsureSessionNotRevoked checks it on every request. "Logout
* everywhere" (Modules\Core\Auth\Services\UserSessionService::
* revokeOtherSessions()) is then just marking every OTHER row
* revoked_at, no session-store-specific logic anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('user_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->string('user_agent')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('last_used_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'revoked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_sessions');
}
};
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
foreach ($existing as $id => $name) {
if ($name === null) {
continue;
}
DB::table('payment_methods')
->where('id', $id)
->update(['name' => json_encode([$defaultLocale => $name])]);
}
}
public function down(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
foreach ($existing as $id => $name) {
$decoded = json_decode((string) $name, true);
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// The column is NOT NULL (vendor migration never marked it
// nullable) — converting via `USING NULL` first, then
// backfilling with a second UPDATE, violates that constraint
// before the backfill ever runs. json_build_object() converts
// each existing string in place, in the same statement, so the
// column is never transiently NULL. $defaultLocale is inlined
// (not bound) — parameter binding inside an ALTER TABLE ... USING
// expression isn't reliable across drivers; it's a Language::code
// value we control, not user input, so quote_literal-safe
// interpolation here is fine.
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
}
public function down(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// Same NOT NULL constraint applies going back — ->>'{locale}'
// extracts the default locale's text value directly in the
// USING clause, falling back to the first key present via
// COALESCE for any row missing that locale (e.g. one only ever
// filled in via a non-default language).
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement(
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
);
}
};