@fleetbase/storefront-engine 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/addon/components/customer-panel/orders.hbs +2 -2
- package/addon/components/modals/create-gateway.hbs +33 -12
- package/addon/components/network-category-picker.js +1 -1
- package/addon/components/order-panel.hbs +1 -1
- package/addon/components/widget/customers.hbs +5 -2
- package/addon/components/widget/customers.js +14 -6
- package/addon/components/widget/orders.hbs +30 -9
- package/addon/components/widget/orders.js +7 -1
- package/addon/components/widget/storefront-key-metrics.js +2 -2
- package/addon/components/widget/storefront-metrics.hbs +11 -1
- package/addon/components/widget/storefront-metrics.js +103 -1
- package/addon/controllers/networks/index/network/index.js +2 -0
- package/addon/controllers/networks/index/network/stores.js +0 -1
- package/addon/controllers/settings/gateways.js +6 -1
- package/addon/controllers/settings/index.js +1 -0
- package/addon/controllers/settings/notifications.js +3 -5
- package/addon/models/network.js +1 -0
- package/addon/models/store.js +1 -0
- package/addon/routes/networks/index/network/index.js +5 -0
- package/addon/routes/settings/index.js +5 -0
- package/addon/services/order-actions.js +31 -0
- package/addon/styles/storefront-engine.css +22 -0
- package/addon/templates/networks/index/network/index.hbs +13 -0
- package/addon/templates/networks/index/network/stores.hbs +8 -1
- package/addon/templates/settings/gateways.hbs +15 -4
- package/addon/templates/settings/index.hbs +13 -0
- package/addon/templates/settings/notifications.hbs +2 -2
- package/addon/utils/commerce-date-ranges.js +263 -0
- package/app/utils/commerce-date-ranges.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/migrations/2025_09_01_041353_add_default_order_config_column.php +110 -0
- package/server/src/Console/Commands/MigrateStripeSandboxCustomers.php +150 -0
- package/server/src/Expansions/OrderExpansion.php +43 -0
- package/server/src/Http/Controllers/NetworkController.php +1 -1
- package/server/src/Http/Controllers/OrderController.php +62 -9
- package/server/src/Http/Controllers/v1/CheckoutController.php +45 -48
- package/server/src/Http/Controllers/v1/ServiceQuoteController.php +19 -3
- package/server/src/Http/Middleware/SetStorefrontSession.php +8 -6
- package/server/src/Http/Resources/Customer.php +41 -17
- package/server/src/Http/Resources/Gateway.php +16 -11
- package/server/src/Http/Resources/Network.php +35 -34
- package/server/src/Http/Resources/NotificationChannel.php +17 -10
- package/server/src/Http/Resources/Store.php +36 -35
- package/server/src/Models/Customer.php +18 -2
- package/server/src/Models/FoodTruck.php +15 -0
- package/server/src/Models/Network.php +63 -1
- package/server/src/Models/Store.php +71 -9
- package/server/src/Notifications/StorefrontOrderAccepted.php +154 -0
- package/server/src/Observers/OrderObserver.php +6 -4
- package/server/src/Providers/StorefrontServiceProvider.php +1 -0
- package/server/src/Rules/IsValidLocation.php +12 -0
- package/server/src/Support/PushNotification.php +13 -4
- package/server/src/Support/Storefront.php +199 -37
- package/server/src/routes.php +1 -0
- package/translations/en-us.yaml +5 -0
|
@@ -22,10 +22,23 @@
|
|
|
22
22
|
@placeholder={{t "storefront.networks.index.network.index.general-network-settings-form.description-placeholder"}}
|
|
23
23
|
@helpText={{t "storefront.networks.index.network.index.general-network-settings-form.description-help-text"}}
|
|
24
24
|
/>
|
|
25
|
+
|
|
25
26
|
<InputGroup @name={{t "storefront.networks.index.network.index.general-network-settings-form.currency"}}>
|
|
26
27
|
<CurrencySelect @currency={{@model.currency}} @onCurrencyChange={{fn (mut @model.currency)}} @triggerClass="w-full form-select" />
|
|
27
28
|
</InputGroup>
|
|
28
29
|
|
|
30
|
+
<InputGroup @name="Default Order Config" @helpText="Select the order config which will apply to all orders created for this network.">
|
|
31
|
+
<Select
|
|
32
|
+
@value={{@model.order_config_uuid}}
|
|
33
|
+
@options={{this.orderConfigs}}
|
|
34
|
+
@optionValue="id"
|
|
35
|
+
@optionLabel="name"
|
|
36
|
+
@onSelect={{fn (mut @model.order_config_uuid)}}
|
|
37
|
+
@placeholder="Select default order config"
|
|
38
|
+
class="w-full"
|
|
39
|
+
/>
|
|
40
|
+
</InputGroup>
|
|
41
|
+
|
|
29
42
|
<ContentPanel @title={{t "storefront.networks.index.network.index.general-network-settings-form.contact-social-panel.panel-title"}} @open={{false}} @pad={{true}}>
|
|
30
43
|
<InputGroup @name={{t "storefront.networks.index.network.index.general-network-settings-form.contact-social-panel.phone"}}>
|
|
31
44
|
<PhoneInput @value={{@model.phone}} @onInput={{fn (mut @model.phone)}} class="form-input w-full" />
|
|
@@ -20,7 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
<Layout::Section::Body>
|
|
22
22
|
<div class="flex flex-row items-center space-x-2">
|
|
23
|
-
<NetworkCategoryPicker
|
|
23
|
+
<NetworkCategoryPicker
|
|
24
|
+
@network={{this.network}}
|
|
25
|
+
@category={{this.category}}
|
|
26
|
+
@onCreateNewCategory={{this.createNewCategory}}
|
|
27
|
+
@onSelect={{this.selectCategory}}
|
|
28
|
+
@onReady={{this.setCategoryPickerContext}}
|
|
29
|
+
@wrapperClass="w-64 my-4 ml-9"
|
|
30
|
+
/>
|
|
24
31
|
{{#if this.categoryModel}}
|
|
25
32
|
<Button @icon="arrow-left" @size="xs" @onClick={{this.categoryPicker.loadParentCategories}} />
|
|
26
33
|
<Button @icon="cog" @text="Edit Category" @size="xs" @onClick={{fn this.editCategory this.categoryModel}} />
|
|
@@ -38,17 +38,28 @@
|
|
|
38
38
|
{{#each-in gateway.config as |key value|}}
|
|
39
39
|
{{#if (is-bool-value value)}}
|
|
40
40
|
<div class="input-group">
|
|
41
|
-
<Checkbox @value={{value}} @label={{humanize key}} />
|
|
41
|
+
<Checkbox @value={{value}} @label={{humanize key}} @disabled={{true}} />
|
|
42
42
|
</div>
|
|
43
43
|
{{else}}
|
|
44
44
|
<InputGroup @name={{humanize key}}>
|
|
45
|
-
<Input
|
|
45
|
+
<Input
|
|
46
|
+
@type={{if (can "storefront update gateway") "text" "password"}}
|
|
47
|
+
class="form-input w-full"
|
|
48
|
+
placeholder={{humanize key}}
|
|
49
|
+
@value={{value}}
|
|
50
|
+
disabled
|
|
51
|
+
/>
|
|
46
52
|
</InputGroup>
|
|
47
53
|
{{/if}}
|
|
48
54
|
{{/each-in}}
|
|
49
55
|
</div>
|
|
50
|
-
<div>
|
|
51
|
-
|
|
56
|
+
<div class="flex flex-row items-center space-x-2">
|
|
57
|
+
{{#if (can "storefront update gateway")}}
|
|
58
|
+
<Button @size="sm" @type="primary" @icon="pencil" @text="Edit Gateway" @onClick={{fn this.editGateway gateway}} />
|
|
59
|
+
{{/if}}
|
|
60
|
+
{{#if (can "storefront delete gateway")}}
|
|
61
|
+
<Button @size="sm" @type="danger" @icon="trash" @text={{t "storefront.settings.gateways.delete-payment-gateway"}} @onClick={{fn this.deleteGateway gateway}} />
|
|
62
|
+
{{/if}}
|
|
52
63
|
</div>
|
|
53
64
|
</ContentPanel>
|
|
54
65
|
{{/each}}
|
|
@@ -36,10 +36,23 @@
|
|
|
36
36
|
{{tag}}
|
|
37
37
|
</TagInput>
|
|
38
38
|
</InputGroup>
|
|
39
|
+
|
|
39
40
|
<InputGroup @name={{t "storefront.common.currency"}}>
|
|
40
41
|
<CurrencySelect @currency={{@model.currency}} @onCurrencyChange={{fn (mut @model.currency)}} @triggerClass="w-full form-select" />
|
|
41
42
|
</InputGroup>
|
|
42
43
|
|
|
44
|
+
<InputGroup @name="Default Order Config" @helpText="Select the order config which will apply to all orders created for this store">
|
|
45
|
+
<Select
|
|
46
|
+
@value={{@model.order_config_uuid}}
|
|
47
|
+
@options={{this.orderConfigs}}
|
|
48
|
+
@optionValue="id"
|
|
49
|
+
@optionLabel="name"
|
|
50
|
+
@onSelect={{fn (mut @model.order_config_uuid)}}
|
|
51
|
+
@placeholder="Select default order config"
|
|
52
|
+
class="w-full"
|
|
53
|
+
/>
|
|
54
|
+
</InputGroup>
|
|
55
|
+
|
|
43
56
|
<ContentPanel @title={{t "storefront.settings.index.contact-social"}} @open={{false}} @pad={{true}}>
|
|
44
57
|
<InputGroup @name={{t "storefront.common.phone"}}>
|
|
45
58
|
<PhoneInput @value={{@model.phone}} @onInput={{fn (mut @model.phone)}} class="form-input w-full" />
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<div class="space-y-3">
|
|
19
|
-
{{#each
|
|
20
|
-
<div class="flex px-4 py-2 items-center justify-between shadow-sm rounded-md dark:bg-gray-900 bg-gray-200">
|
|
19
|
+
{{#each @model as |notificationChannel|}}
|
|
20
|
+
<div class="flex px-4 py-2 items-center justify-between shadow-sm rounded-md dark:bg-gray-900 bg-gray-200 border border-gray-200 dark:border-gray-700">
|
|
21
21
|
<div>
|
|
22
22
|
<span class="dark:text-gray-50">{{notificationChannel.name}}</span>
|
|
23
23
|
</div>
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startOfDay,
|
|
3
|
+
endOfDay,
|
|
4
|
+
startOfWeek,
|
|
5
|
+
endOfWeek,
|
|
6
|
+
startOfMonth,
|
|
7
|
+
endOfMonth,
|
|
8
|
+
startOfQuarter,
|
|
9
|
+
endOfQuarter,
|
|
10
|
+
startOfYear,
|
|
11
|
+
endOfYear,
|
|
12
|
+
subDays,
|
|
13
|
+
subWeeks,
|
|
14
|
+
subMonths,
|
|
15
|
+
subQuarters,
|
|
16
|
+
subYears,
|
|
17
|
+
format,
|
|
18
|
+
} from 'date-fns';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Predefined date range buttons for ecommerce analytics dashboard
|
|
22
|
+
* Each button contains a label and a function that returns [startDate, endDate]
|
|
23
|
+
*/
|
|
24
|
+
export const predefinedDateRanges = [
|
|
25
|
+
// Recent periods - most commonly used for daily monitoring
|
|
26
|
+
{
|
|
27
|
+
label: 'Today',
|
|
28
|
+
getValue: () => {
|
|
29
|
+
const today = new Date();
|
|
30
|
+
return [startOfDay(today), endOfDay(today)];
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Yesterday',
|
|
35
|
+
getValue: () => {
|
|
36
|
+
const yesterday = subDays(new Date(), 1);
|
|
37
|
+
return [startOfDay(yesterday), endOfDay(yesterday)];
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: 'Last 7 Days',
|
|
42
|
+
getValue: () => {
|
|
43
|
+
const today = new Date();
|
|
44
|
+
const sevenDaysAgo = subDays(today, 6); // 6 days ago + today = 7 days
|
|
45
|
+
return [startOfDay(sevenDaysAgo), endOfDay(today)];
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: 'Last 14 Days',
|
|
50
|
+
getValue: () => {
|
|
51
|
+
const today = new Date();
|
|
52
|
+
const fourteenDaysAgo = subDays(today, 13);
|
|
53
|
+
return [startOfDay(fourteenDaysAgo), endOfDay(today)];
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
label: 'Last 30 Days',
|
|
58
|
+
getValue: () => {
|
|
59
|
+
const today = new Date();
|
|
60
|
+
const thirtyDaysAgo = subDays(today, 29);
|
|
61
|
+
return [startOfDay(thirtyDaysAgo), endOfDay(today)];
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// Weekly periods
|
|
66
|
+
{
|
|
67
|
+
label: 'This Week',
|
|
68
|
+
getValue: () => {
|
|
69
|
+
const today = new Date();
|
|
70
|
+
return [startOfWeek(today, { weekStartsOn: 1 }), endOfWeek(today, { weekStartsOn: 1 })]; // Monday start
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
label: 'Last Week',
|
|
75
|
+
getValue: () => {
|
|
76
|
+
const lastWeek = subWeeks(new Date(), 1);
|
|
77
|
+
return [startOfWeek(lastWeek, { weekStartsOn: 1 }), endOfWeek(lastWeek, { weekStartsOn: 1 })];
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Monthly periods - crucial for monthly reporting
|
|
82
|
+
{
|
|
83
|
+
label: 'This Month',
|
|
84
|
+
getValue: () => {
|
|
85
|
+
const today = new Date();
|
|
86
|
+
return [startOfMonth(today), endOfMonth(today)];
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
label: 'Last Month',
|
|
91
|
+
getValue: () => {
|
|
92
|
+
const lastMonth = subMonths(new Date(), 1);
|
|
93
|
+
return [startOfMonth(lastMonth), endOfMonth(lastMonth)];
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: 'Last 3 Months',
|
|
98
|
+
getValue: () => {
|
|
99
|
+
const today = new Date();
|
|
100
|
+
const threeMonthsAgo = subMonths(today, 3);
|
|
101
|
+
return [startOfMonth(threeMonthsAgo), endOfMonth(today)];
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
label: 'Last 6 Months',
|
|
106
|
+
getValue: () => {
|
|
107
|
+
const today = new Date();
|
|
108
|
+
const sixMonthsAgo = subMonths(today, 6);
|
|
109
|
+
return [startOfMonth(sixMonthsAgo), endOfMonth(today)];
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Quarterly periods - important for business reporting
|
|
114
|
+
{
|
|
115
|
+
label: 'This Quarter',
|
|
116
|
+
getValue: () => {
|
|
117
|
+
const today = new Date();
|
|
118
|
+
return [startOfQuarter(today), endOfQuarter(today)];
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
label: 'Last Quarter',
|
|
123
|
+
getValue: () => {
|
|
124
|
+
const lastQuarter = subQuarters(new Date(), 1);
|
|
125
|
+
return [startOfQuarter(lastQuarter), endOfQuarter(lastQuarter)];
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: 'Q1 2024',
|
|
130
|
+
getValue: () => {
|
|
131
|
+
const q1Start = new Date(2024, 0, 1); // January 1, 2024
|
|
132
|
+
return [startOfQuarter(q1Start), endOfQuarter(q1Start)];
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: 'Q2 2024',
|
|
137
|
+
getValue: () => {
|
|
138
|
+
const q2Start = new Date(2024, 3, 1); // April 1, 2024
|
|
139
|
+
return [startOfQuarter(q2Start), endOfQuarter(q2Start)];
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
label: 'Q3 2024',
|
|
144
|
+
getValue: () => {
|
|
145
|
+
const q3Start = new Date(2024, 6, 1); // July 1, 2024
|
|
146
|
+
return [startOfQuarter(q3Start), endOfQuarter(q3Start)];
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
label: 'Q4 2024',
|
|
151
|
+
getValue: () => {
|
|
152
|
+
const q4Start = new Date(2024, 9, 1); // October 1, 2024
|
|
153
|
+
return [startOfQuarter(q4Start), endOfQuarter(q4Start)];
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// Yearly periods - essential for annual analysis
|
|
158
|
+
{
|
|
159
|
+
label: 'This Year',
|
|
160
|
+
getValue: () => {
|
|
161
|
+
const today = new Date();
|
|
162
|
+
return [startOfYear(today), endOfYear(today)];
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
label: 'Last Year',
|
|
167
|
+
getValue: () => {
|
|
168
|
+
const lastYear = subYears(new Date(), 1);
|
|
169
|
+
return [startOfYear(lastYear), endOfYear(lastYear)];
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
label: '2024',
|
|
174
|
+
getValue: () => {
|
|
175
|
+
const year2024 = new Date(2024, 0, 1);
|
|
176
|
+
return [startOfYear(year2024), endOfYear(year2024)];
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
label: '2023',
|
|
181
|
+
getValue: () => {
|
|
182
|
+
const year2023 = new Date(2023, 0, 1);
|
|
183
|
+
return [startOfYear(year2023), endOfYear(year2023)];
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
// Special ecommerce periods
|
|
188
|
+
{
|
|
189
|
+
label: 'Black Friday Week',
|
|
190
|
+
getValue: () => {
|
|
191
|
+
// Assuming Black Friday 2024 is November 29th
|
|
192
|
+
const blackFriday = new Date(2024, 10, 29); // November 29, 2024
|
|
193
|
+
const weekStart = subDays(blackFriday, 3); // Tuesday before
|
|
194
|
+
const weekEnd = subDays(blackFriday, -3); // Monday after
|
|
195
|
+
return [startOfDay(weekStart), endOfDay(weekEnd)];
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
label: 'Holiday Season 2024',
|
|
200
|
+
getValue: () => {
|
|
201
|
+
// November 1st to December 31st
|
|
202
|
+
const seasonStart = new Date(2024, 10, 1); // November 1, 2024
|
|
203
|
+
const seasonEnd = new Date(2024, 11, 31); // December 31, 2024
|
|
204
|
+
return [startOfDay(seasonStart), endOfDay(seasonEnd)];
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
label: 'Back to School 2024',
|
|
209
|
+
getValue: () => {
|
|
210
|
+
// August 1st to September 15th
|
|
211
|
+
const seasonStart = new Date(2024, 7, 1); // August 1, 2024
|
|
212
|
+
const seasonEnd = new Date(2024, 8, 15); // September 15, 2024
|
|
213
|
+
return [startOfDay(seasonStart), endOfDay(seasonEnd)];
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convert predefined date ranges to AirDatepicker buttons format
|
|
220
|
+
* @param {Function} onRangeSelect - Callback function when a range is selected
|
|
221
|
+
* @returns {Array} Array of button objects for AirDatepicker
|
|
222
|
+
*/
|
|
223
|
+
export function createDateRangeButtons(onRangeSelect) {
|
|
224
|
+
return predefinedDateRanges.map((range) => ({
|
|
225
|
+
content: range.label,
|
|
226
|
+
className: 'custom-date-range-btn',
|
|
227
|
+
onClick: (datepicker) => {
|
|
228
|
+
const [startDate, endDate] = range.getValue();
|
|
229
|
+
datepicker.selectDate([startDate, endDate]);
|
|
230
|
+
datepicker.hide();
|
|
231
|
+
|
|
232
|
+
// Call the callback if provided
|
|
233
|
+
if (typeof onRangeSelect === 'function') {
|
|
234
|
+
onRangeSelect({
|
|
235
|
+
label: range.label,
|
|
236
|
+
startDate,
|
|
237
|
+
endDate,
|
|
238
|
+
formattedRange: `${format(startDate, 'MMM dd, yyyy')} - ${format(endDate, 'MMM dd, yyyy')}`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get a specific date range by label
|
|
247
|
+
* @param {string} label - The label of the date range
|
|
248
|
+
* @returns {Array|null} [startDate, endDate] or null if not found
|
|
249
|
+
*/
|
|
250
|
+
export function getDateRangeByLabel(label) {
|
|
251
|
+
const range = predefinedDateRanges.find((r) => r.label === label);
|
|
252
|
+
return range ? range.getValue() : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Format date range for display
|
|
257
|
+
* @param {Date} startDate
|
|
258
|
+
* @param {Date} endDate
|
|
259
|
+
* @returns {string} Formatted date range string
|
|
260
|
+
*/
|
|
261
|
+
export function formatDateRange(startDate, endDate) {
|
|
262
|
+
return `${format(startDate, 'MMM dd, yyyy')} - ${format(endDate, 'MMM dd, yyyy')}`;
|
|
263
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/storefront-engine/utils/commerce-date-ranges';
|
package/composer.json
CHANGED
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\Storefront\Support\Storefront;
|
|
4
|
+
use Fleetbase\Support\Utils;
|
|
5
|
+
use Illuminate\Database\Migrations\Migration;
|
|
6
|
+
use Illuminate\Database\Query\Expression;
|
|
7
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
8
|
+
use Illuminate\Support\Facades\DB;
|
|
9
|
+
use Illuminate\Support\Facades\Schema;
|
|
10
|
+
|
|
11
|
+
return new class extends Migration {
|
|
12
|
+
/**
|
|
13
|
+
* Run the migrations.
|
|
14
|
+
*/
|
|
15
|
+
public function up(): void
|
|
16
|
+
{
|
|
17
|
+
$databaseName = Utils::getFleetbaseDatabaseName();
|
|
18
|
+
$sfConnection = config('storefront.connection.db');
|
|
19
|
+
|
|
20
|
+
Schema::connection($sfConnection)->table('stores', function (Blueprint $table) use ($databaseName) {
|
|
21
|
+
// nullable because we’ll backfill after
|
|
22
|
+
$table->foreignUuid('order_config_uuid')
|
|
23
|
+
->nullable()
|
|
24
|
+
->after('backdrop_uuid')
|
|
25
|
+
->constrained(new Expression($databaseName . '.order_configs'), 'uuid')
|
|
26
|
+
->nullOnDelete();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Schema::connection($sfConnection)->table('networks', function (Blueprint $table) use ($databaseName) {
|
|
30
|
+
$table->foreignUuid('order_config_uuid')
|
|
31
|
+
->nullable()
|
|
32
|
+
->after('backdrop_uuid')
|
|
33
|
+
->constrained(new Expression($databaseName . '.order_configs'), 'uuid')
|
|
34
|
+
->nullOnDelete();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
DB::connection($sfConnection)->transaction(function () use ($sfConnection) {
|
|
38
|
+
// Pull valid company UUIDs from core.companies
|
|
39
|
+
$validCompanyUuids = DB::connection(config('database.default'))
|
|
40
|
+
->table('companies')
|
|
41
|
+
->pluck('uuid')
|
|
42
|
+
->all();
|
|
43
|
+
|
|
44
|
+
// STORES: distinct companies missing an order_config_uuid
|
|
45
|
+
$storeCompanyIds = DB::connection($sfConnection)
|
|
46
|
+
->table('stores')
|
|
47
|
+
->whereNull('order_config_uuid')
|
|
48
|
+
->whereNotNull('company_uuid')
|
|
49
|
+
->distinct()
|
|
50
|
+
->pluck('company_uuid')
|
|
51
|
+
->filter(fn ($uuid) => in_array($uuid, $validCompanyUuids, true))
|
|
52
|
+
->values();
|
|
53
|
+
|
|
54
|
+
foreach ($storeCompanyIds as $companyUuid) {
|
|
55
|
+
// Resolve company-scoped default config
|
|
56
|
+
$config = Storefront::getOrderConfig($companyUuid);
|
|
57
|
+
if ($config) {
|
|
58
|
+
DB::connection($sfConnection)
|
|
59
|
+
->table('stores')
|
|
60
|
+
->where('company_uuid', $companyUuid)
|
|
61
|
+
->whereNull('order_config_uuid')
|
|
62
|
+
->update(['order_config_uuid' => $config->uuid]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// NETWORKS: distinct companies missing an order_config_uuid
|
|
67
|
+
$networkCompanyIds = DB::connection($sfConnection)
|
|
68
|
+
->table('networks')
|
|
69
|
+
->whereNull('order_config_uuid')
|
|
70
|
+
->whereNotNull('company_uuid')
|
|
71
|
+
->distinct()
|
|
72
|
+
->pluck('company_uuid')
|
|
73
|
+
->filter(fn ($uuid) => in_array($uuid, $validCompanyUuids, true))
|
|
74
|
+
->values();
|
|
75
|
+
|
|
76
|
+
foreach ($networkCompanyIds as $companyUuid) {
|
|
77
|
+
$config = Storefront::getOrderConfig($companyUuid);
|
|
78
|
+
if ($config) {
|
|
79
|
+
DB::connection($sfConnection)
|
|
80
|
+
->table('networks')
|
|
81
|
+
->where('company_uuid', $companyUuid)
|
|
82
|
+
->whereNull('order_config_uuid')
|
|
83
|
+
->update(['order_config_uuid' => $config->uuid]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reverse the migrations.
|
|
91
|
+
*/
|
|
92
|
+
public function down(): void
|
|
93
|
+
{
|
|
94
|
+
$sfConnection = config('storefront.connection.db');
|
|
95
|
+
|
|
96
|
+
// Drop FK and column for stores
|
|
97
|
+
Schema::connection($sfConnection)->table('stores', function (Blueprint $table) {
|
|
98
|
+
// Drop the foreign key constraint first, then the column
|
|
99
|
+
// Default FK name pattern: {table}_{column}_foreign
|
|
100
|
+
$table->dropForeign(['order_config_uuid']);
|
|
101
|
+
$table->dropColumn('order_config_uuid');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Drop FK and column for networks
|
|
105
|
+
Schema::connection($sfConnection)->table('networks', function (Blueprint $table) {
|
|
106
|
+
$table->dropForeign(['order_config_uuid']);
|
|
107
|
+
$table->dropColumn('order_config_uuid');
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\Storefront\Console\Commands;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Storefront\Models\Customer;
|
|
6
|
+
use Fleetbase\Storefront\Models\Gateway;
|
|
7
|
+
use Fleetbase\Storefront\Models\Store;
|
|
8
|
+
use Illuminate\Console\Command;
|
|
9
|
+
use Stripe\Customer as StripeCustomer;
|
|
10
|
+
use Stripe\Exception\InvalidRequestException;
|
|
11
|
+
use Stripe\Stripe;
|
|
12
|
+
|
|
13
|
+
class MigrateStripeSandboxCustomers extends Command
|
|
14
|
+
{
|
|
15
|
+
/**
|
|
16
|
+
* The name and signature of the console command.
|
|
17
|
+
*
|
|
18
|
+
* Added a --store option to allow running the migration for a single store by UUID or public_id.
|
|
19
|
+
*/
|
|
20
|
+
protected $signature = 'storefront:migrate-stripe-customers
|
|
21
|
+
{--dry-run : Don\'t actually create or update anything}
|
|
22
|
+
{--store= : Specify a store UUID or public_id to migrate only that store}';
|
|
23
|
+
|
|
24
|
+
/** The console command description. */
|
|
25
|
+
protected $description = 'Migrates Stripe customers created in test mode to live mode and updates contact metadata.';
|
|
26
|
+
|
|
27
|
+
/** Execute the console command. */
|
|
28
|
+
public function handle(): int
|
|
29
|
+
{
|
|
30
|
+
$dryRun = $this->option('dry-run');
|
|
31
|
+
$storeInput = $this->option('store');
|
|
32
|
+
|
|
33
|
+
$this->info('Starting Stripe customer migration...');
|
|
34
|
+
|
|
35
|
+
// If a single store is specified, load that store by UUID or public_id.
|
|
36
|
+
if ($storeInput) {
|
|
37
|
+
$store = Store::where('uuid', $storeInput)
|
|
38
|
+
->orWhere('public_id', $storeInput)
|
|
39
|
+
->first();
|
|
40
|
+
|
|
41
|
+
if (!$store) {
|
|
42
|
+
$this->error("Store '{$storeInput}' not found.");
|
|
43
|
+
|
|
44
|
+
return Command::FAILURE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
$this->migrateCustomers($store, $dryRun);
|
|
48
|
+
} else {
|
|
49
|
+
// Otherwise, load all stores.
|
|
50
|
+
$stores = Store::all();
|
|
51
|
+
foreach ($stores as $store) {
|
|
52
|
+
$this->migrateCustomers($store, $dryRun);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$this->info('Stripe customer migration complete.');
|
|
57
|
+
|
|
58
|
+
return Command::SUCCESS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Migrates all customers for a given store whose stripe_id is still pointing at test mode.
|
|
63
|
+
*
|
|
64
|
+
* Only runs when the store's gateway is configured for live mode; skips migration for sandbox gateways.
|
|
65
|
+
*/
|
|
66
|
+
public function migrateCustomers(Store $store, bool $dryRun): int
|
|
67
|
+
{
|
|
68
|
+
// Find the Stripe gateway for this store (there is only one gateway per store).
|
|
69
|
+
$gateway = Gateway::where([
|
|
70
|
+
'code' => 'stripe',
|
|
71
|
+
'owner_uuid'=> $store->uuid,
|
|
72
|
+
])->first();
|
|
73
|
+
|
|
74
|
+
if (!$gateway) {
|
|
75
|
+
$this->warn("Store {$store->name}: no Stripe gateway configured.");
|
|
76
|
+
|
|
77
|
+
return Command::SUCCESS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If the gateway is still sandbox, skip migration because we can't create live customers yet.
|
|
81
|
+
if ($gateway->sandbox) {
|
|
82
|
+
$this->warn("Store {$store->name} is using a sandbox gateway. Migration will only run when the gateway is switched to live.");
|
|
83
|
+
|
|
84
|
+
return Command::SUCCESS;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inform the store being migrated
|
|
88
|
+
$this->info("Store {$store->name}: Will have sandbox customers migrated...");
|
|
89
|
+
|
|
90
|
+
$secretKey = $gateway->config->secret_key;
|
|
91
|
+
|
|
92
|
+
// Process customers in chunks to conserve memory.
|
|
93
|
+
Customer::where('company_uuid', $store->company_uuid)->chunk(50, function ($customers) use ($dryRun, $secretKey, $store) {
|
|
94
|
+
foreach ($customers as $customer) {
|
|
95
|
+
$stripeId = $customer->getMeta('stripe_id');
|
|
96
|
+
if (!$stripeId) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Set the Stripe API key to the store's live secret key.
|
|
101
|
+
Stripe::setApiKey($secretKey);
|
|
102
|
+
|
|
103
|
+
$current = null;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
// Attempt to retrieve the customer using the current key.
|
|
107
|
+
// If found and livemode is true, no migration needed.
|
|
108
|
+
$current = StripeCustomer::retrieve($stripeId);
|
|
109
|
+
if ($current && $current->livemode === true) {
|
|
110
|
+
$this->line("Customer {$customer->id}: Stripe ID {$stripeId} is already a live customer.");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
} catch (InvalidRequestException $e) {
|
|
114
|
+
// The existing ID was not found with the live key, so it likely belongs to the test environment.
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If we're doing a dry run, just report what would happen.
|
|
118
|
+
if ($dryRun) {
|
|
119
|
+
$this->info("Customer {$customer->id}: Would migrate test Stripe ID {$stripeId} to live.");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create a new live customer using the contact details.
|
|
124
|
+
$metadata = [
|
|
125
|
+
'contact_id' => $customer->public_id,
|
|
126
|
+
'storefront_id' => $store->public_id,
|
|
127
|
+
'company_id' => $store->company_uuid,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
$params = [
|
|
131
|
+
'description' => 'Customer migrated from sandbox',
|
|
132
|
+
'email' => $customer->email,
|
|
133
|
+
'name' => $customer->name,
|
|
134
|
+
'phone' => $customer->phone,
|
|
135
|
+
'metadata' => $metadata,
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
$newCustomer = StripeCustomer::create($params);
|
|
139
|
+
|
|
140
|
+
// Save the old test ID and update the live ID.
|
|
141
|
+
$customer->updateMeta('stripe_id_sandbox', $stripeId);
|
|
142
|
+
$customer->updateMeta('stripe_id', $newCustomer->id);
|
|
143
|
+
|
|
144
|
+
$this->info("Customer {$customer->id}: Migrated test ID {$stripeId} to live Stripe ID {$newCustomer->id}.");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return Command::SUCCESS;
|
|
149
|
+
}
|
|
150
|
+
}
|