From a13ca3b5929b2945af7cfadc1ddf6e1b80f020d1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 17:59:57 +0800 Subject: [PATCH 1/9] feat(app-review): a store can let app store reviewers sign in without SMS App Review access is a per-store and per-network setting: reviewers sign in with the fictional number +1 202 555 0143, which is never texted, and a six-digit code the owner creates in the Console (stored hashed, shown once, accepted only by the store that made it). Covers sign-in, sign-up, adding the number at checkout and account closure. Reviewers' orders get meta.app_review, are marked in the order panel and staff alerts, and are never auto-dispatched. The env review accounts stay as a self-hosted fallback. v0.4.30 release notes. --- README.md | 4 +- RELEASE.md | 11 +- .../storefront/app-review-access.hbs | 79 +++ .../storefront/app-review-access.js | 112 ++++ .../components/storefront/order/overview.hbs | 3 +- .../storefront/order/panel-app-review.hbs | 12 + .../storefront/order/panel-app-review.js | 11 + .../storefront/order/panel-header.hbs | 2 +- .../storefront/order/panel-header.js | 8 + .../networks/index/network/settings.js | 1 + addon/controllers/settings.js | 1 + addon/routes.js | 1 + addon/routes/settings/app-review.js | 10 + addon/styles/storefront-engine.css | 90 +++ .../networks/index/network/settings.hbs | 6 + addon/templates/settings/app-review.hbs | 10 + .../storefront/app-review-access.js | 1 + .../storefront/order/panel-app-review.js | 1 + app/routes/settings/app-review.js | 1 + app/templates/settings/app-review.js | 1 + composer.json | 2 +- docs/app-review.md | 102 ++++ extension.json | 2 +- package.json | 2 +- ...00000_create_app_review_accesses_table.php | 47 ++ server/src/Auth/Schemas/Storefront.php | 4 +- .../Http/Controllers/NetworkController.php | 10 + .../src/Http/Controllers/StoreController.php | 11 + .../Controllers/v1/CheckoutController.php | 9 +- .../Controllers/v1/CustomerController.php | 152 +++-- server/src/Models/AppReviewAccess.php | 70 +++ .../Notifications/StorefrontOrderCreated.php | 9 + server/src/Support/AppReview.php | 258 +++++++++ server/src/Support/Storefront.php | 5 + server/src/Traits/ManagesAppReviewAccess.php | 57 ++ server/src/routes.php | 4 + .../Http/Controllers/AppReviewAccessTest.php | 518 ++++++++++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 4 + .../storefront/app-review-access-test.js | 131 +++++ .../storefront/order/panel-app-review-test.js | 22 + translations/en-us.yaml | 26 + 41 files changed, 1752 insertions(+), 58 deletions(-) create mode 100644 addon/components/storefront/app-review-access.hbs create mode 100644 addon/components/storefront/app-review-access.js create mode 100644 addon/components/storefront/order/panel-app-review.hbs create mode 100644 addon/components/storefront/order/panel-app-review.js create mode 100644 addon/routes/settings/app-review.js create mode 100644 addon/templates/settings/app-review.hbs create mode 100644 app/components/storefront/app-review-access.js create mode 100644 app/components/storefront/order/panel-app-review.js create mode 100644 app/routes/settings/app-review.js create mode 100644 app/templates/settings/app-review.js create mode 100644 docs/app-review.md create mode 100644 server/migrations/2026_10_24_000000_create_app_review_accesses_table.php create mode 100644 server/src/Models/AppReviewAccess.php create mode 100644 server/src/Support/AppReview.php create mode 100644 server/src/Traits/ManagesAppReviewAccess.php create mode 100644 server/tests/Unit/Http/Controllers/AppReviewAccessTest.php create mode 100644 tests/integration/components/storefront/app-review-access-test.js create mode 100644 tests/integration/components/storefront/order/panel-app-review-test.js diff --git a/README.md b/README.md index 13e45c53..896bd6a1 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,8 @@ Important environment variables include: | Variable | Purpose | | --- | --- | | `STOREFRONT_DB_CONNECTION` | Database connection name for Storefront models. | -| `STOREFRONT_BYPASS_VERIFICATION_CODE` | Development bypass code for Storefront app verification flows. | +| `STOREFRONT_BYPASS_VERIFICATION_CODE` | Instance-wide sign-in code for the review accounts in `STOREFRONT_REVIEW_ACCOUNTS` (self-hosted). Stores can instead turn on [App Review access](docs/app-review.md) in their settings. | +| `STOREFRONT_REVIEW_ACCOUNTS` | Comma-separated phone numbers and emails that sign in with `STOREFRONT_BYPASS_VERIFICATION_CODE` and are never sent a code. | | `STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE` | Public Storefront API requests allowed per device (store key + client IP) per window. Default `120`. | | `STOREFRONT_THROTTLE_DECAY_MINUTES` | Public Storefront API throttle window in minutes. Default `1`. | @@ -248,6 +249,7 @@ For customer app configuration, see the [Storefront App configuration docs](http - [Storefront documentation](https://www.fleetbase.io/docs/storefront) - [Products and catalog](https://docs.fleetbase.io/guides/storefront/products/) - [Orders and checkout](https://www.fleetbase.io/docs/storefront/orders/overview) +- [App Review access](docs/app-review.md): letting Apple and Google reviewers sign in to a store's app - [Checkout flow](https://www.fleetbase.io/docs/storefront/orders/checkout) - [Store locations](https://www.fleetbase.io/docs/storefront/stores/store-locations) - [Storefront App configuration](https://www.fleetbase.io/docs/storefront/app/configuration) diff --git a/RELEASE.md b/RELEASE.md index 4205c6bf..414f3f39 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,17 @@ -> v0.4.29 ~ "A phone sign-up keeps its number" +> v0.4.30 ~ "App store reviewers can sign in" --- ## Highlights -- **Phone sign-up saves the number.** Signing up with a phone number and texted code created the account without that number: the app sends it as the `identity` the code was filed under, and account creation only read a separate `phone` field. Customers were then asked to verify the same number again, couldn't sign back in by phone, and the same number could sign up a second time. The verified identity is now the account's phone, on the user and the customer contact, and is marked verified (`phone_verified_at`), as is an existing user's matching number. A `phone` sent alongside still takes precedence. Email sign-up is unchanged. +- **App Review access.** Apple and Google reviewers can't receive the texted sign-in code, and Apple rejects apps its reviewers can't sign in to. A store or network can now turn on **Settings → App Review**: reviewers sign in with the test number +1 202 555 0143 (a range kept for fiction, never texted) and a six-digit code the owner creates in the Console and enters in App Store Connect and Google Play Console. The code is stored hashed and shown once; it works only in the store that created it. Reviewers can sign up, sign in, add the number at checkout and delete their account without a text message, on Fleetbase Cloud as well as self-hosted. See [docs/app-review.md](docs/app-review.md). +- **Reviewers' orders are marked.** Orders placed by the review account carry `meta.app_review = true`, are labelled **App Review order** in the order panel and staff alerts, and are never auto-dispatched to a driver. +- **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code. +- **New internal endpoints** `GET|PUT /storefront/int/v1/stores/{id}/app-review` and `.../networks/{id}/app-review`, and a new permission, `storefront manage-app-review store` / `network`. --- ## Upgrading -- No migrations. -- Accounts created by phone before this release have no phone saved. They are asked to add it once in the app (Storefront App v0.1.1 also asks at checkout). +- Run migrations: adds the `app_review_accesses` table on the Storefront connection. +- Grant `manage-app-review` on stores or networks to staff who aren't administrators and should manage App Review access (the Storefront Administrator role's full-access policy already covers it). --- ## Need help? diff --git a/addon/components/storefront/app-review-access.hbs b/addon/components/storefront/app-review-access.hbs new file mode 100644 index 00000000..63c235d1 --- /dev/null +++ b/addon/components/storefront/app-review-access.hbs @@ -0,0 +1,79 @@ +{{! App Review access: the test number and code Apple and Google reviewers sign in with. Buttons only, no inputs, so it can sit inside a settings form without marking it dirty. }} +
+
+

{{t "storefront.settings.app-review.lede"}}

+ {{#if this.status}} + + {{/if}} +
+ + {{#if this.load.isRunning}} +
+ {{else if this.status}} +
+ + {{this.status.phone_display}} + + + {{#if this.code}} + {{this.code}} +

{{t "storefront.settings.app-review.code-new"}}

+ {{else if this.status.has_code}} +
••••••
+

{{t "storefront.settings.app-review.code-hidden" date=this.codeCreatedAt}}

+ {{else}} +

{{t "storefront.settings.app-review.code-none"}}

+ {{/if}} +
+
+ + + +
+ {{#if this.enabled}} +
+ {{/if}} +
\ No newline at end of file diff --git a/addon/components/storefront/app-review-access.js b/addon/components/storefront/app-review-access.js new file mode 100644 index 00000000..16cbc111 --- /dev/null +++ b/addon/components/storefront/app-review-access.js @@ -0,0 +1,112 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * App Review access for a store or network: lets Apple and Google reviewers sign in to its app + * with a fixed test number (never texted) and a code the owner gives the app store. The code + * is stored hashed on the server, so it is shown only in the response that created it. + * + * Args: @owner (a store or network record). + */ +export default class StorefrontAppReviewAccessComponent extends Component { + @service fetch; + @service intl; + @service notifications; + @service modalsManager; + + @tracked status = null; + @tracked code = null; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get type() { + return this.args.owner?.constructor?.modelName === 'network' ? 'network' : 'store'; + } + + get endpoint() { + return `${this.type}s/${this.args.owner?.id}/app-review`; + } + + get permission() { + return `storefront manage-app-review ${this.type}`; + } + + get enabled() { + return this.status?.enabled === true; + } + + get codeCreatedAt() { + const at = this.status?.code_generated_at; + return at ? new Date(at).toLocaleString() : null; + } + + /** Another store or network was opened in the same place: forget the last one's code. */ + @action reload() { + this.status = null; + this.code = null; + this.load.perform(); + } + + @task *load() { + if (!this.args.owner?.id) return; + + try { + const { app_review } = yield this.fetch.get(this.endpoint, {}, { namespace: 'storefront/int/v1' }); + this.status = app_review; + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *save(payload, message) { + try { + const { app_review } = yield this.fetch.put(this.endpoint, payload, { namespace: 'storefront/int/v1' }); + const { code, ...status } = app_review; + this.status = status; + if (code) this.code = code; + if (!status.enabled) this.code = null; + this.notifications.success(message); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action turnOn() { + this.save.perform({ enabled: true }, this.intl.t('storefront.settings.app-review.turned-on')); + } + + @action turnOff() { + this.modalsManager.confirm({ + title: this.intl.t('storefront.settings.app-review.turn-off-title'), + body: this.intl.t('storefront.settings.app-review.turn-off-body', { name: this.args.owner?.name }), + acceptButtonText: this.intl.t('storefront.settings.app-review.turn-off'), + acceptButtonType: 'danger', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ enabled: false }, this.intl.t('storefront.settings.app-review.turned-off')); + modal.done(); + }, + }); + } + + @action regenerate() { + this.modalsManager.confirm({ + title: this.intl.t('storefront.settings.app-review.regenerate-title'), + body: this.intl.t('storefront.settings.app-review.regenerate-body'), + acceptButtonText: this.intl.t('storefront.settings.app-review.regenerate'), + acceptButtonType: 'danger', + acceptButtonIcon: 'rotate', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ regenerate_code: true }, this.intl.t('storefront.settings.app-review.regenerated')); + modal.done(); + }, + }); + } +} diff --git a/addon/components/storefront/order/overview.hbs b/addon/components/storefront/order/overview.hbs index 24eebc07..e41a9870 100644 --- a/addon/components/storefront/order/overview.hbs +++ b/addon/components/storefront/order/overview.hbs @@ -1,5 +1,6 @@ -{{! The 11a order panel body (the first tab): the customer's note, people, delivery, proof of delivery when there is any, items, staff notes, then the folded paperwork; messages open in a drawer beside the panel. The next step, steps and notices are in the header. }} +{{! The 11a order panel body (the first tab): the App Review mark on a reviewer's test order, the customer's note, people, delivery, proof of delivery when there is any, items, staff notes, then the folded paperwork; messages open in a drawer beside the panel. The next step, steps and notices are in the header. }}
+ diff --git a/addon/components/storefront/order/panel-app-review.hbs b/addon/components/storefront/order/panel-app-review.hbs new file mode 100644 index 00000000..dfc18e0c --- /dev/null +++ b/addon/components/storefront/order/panel-app-review.hbs @@ -0,0 +1,12 @@ +{{! An app store reviewer placed this order to test the app. Shown first so the store does not fulfil it. }} +{{#if this.isAppReview}} +
+
+ +
+
{{t "storefront.order.panel.app-review"}}
+

{{t "storefront.order.panel.app-review-body"}}

+
+
+
+{{/if}} \ No newline at end of file diff --git a/addon/components/storefront/order/panel-app-review.js b/addon/components/storefront/order/panel-app-review.js new file mode 100644 index 00000000..d5d324cb --- /dev/null +++ b/addon/components/storefront/order/panel-app-review.js @@ -0,0 +1,11 @@ +import Component from '@glimmer/component'; + +/** + * Marks an order an app store reviewer placed (`meta.app_review`, set at checkout when the + * customer is the App Review account) so the store does not fulfil it. + */ +export default class StorefrontOrderPanelAppReviewComponent extends Component { + get isAppReview() { + return this.args.order?.meta?.app_review === true; + } +} diff --git a/addon/components/storefront/order/panel-header.hbs b/addon/components/storefront/order/panel-header.hbs index 61d1d933..9b408619 100644 --- a/addon/components/storefront/order/panel-header.hbs +++ b/addon/components/storefront/order/panel-header.hbs @@ -57,7 +57,7 @@
- + {{#if this.storeName}}{{/if}} {{#if this.order.meta.food_truck_id}} +
+ + + +
+
{{#if this.isDirty}} diff --git a/addon/templates/settings/app-review.hbs b/addon/templates/settings/app-review.hbs new file mode 100644 index 00000000..d8a3526d --- /dev/null +++ b/addon/templates/settings/app-review.hbs @@ -0,0 +1,10 @@ +
+
+ + {{#if @model}} + + {{/if}} + +
+ +
\ No newline at end of file diff --git a/app/components/storefront/app-review-access.js b/app/components/storefront/app-review-access.js new file mode 100644 index 00000000..52f764a7 --- /dev/null +++ b/app/components/storefront/app-review-access.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/app-review-access'; diff --git a/app/components/storefront/order/panel-app-review.js b/app/components/storefront/order/panel-app-review.js new file mode 100644 index 00000000..4ef888a7 --- /dev/null +++ b/app/components/storefront/order/panel-app-review.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/panel-app-review'; diff --git a/app/routes/settings/app-review.js b/app/routes/settings/app-review.js new file mode 100644 index 00000000..c5568083 --- /dev/null +++ b/app/routes/settings/app-review.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/routes/settings/app-review'; diff --git a/app/templates/settings/app-review.js b/app/templates/settings/app-review.js new file mode 100644 index 00000000..48d8788c --- /dev/null +++ b/app/templates/settings/app-review.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/templates/settings/app-review'; diff --git a/composer.json b/composer.json index 0b5ce9e4..ee591687 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.29", + "version": "0.4.30", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/docs/app-review.md b/docs/app-review.md new file mode 100644 index 00000000..52936026 --- /dev/null +++ b/docs/app-review.md @@ -0,0 +1,102 @@ +# App Review access + +Apple and Google test every app before it goes on sale. Your Storefront app signs +customers in with a code sent by text message, and app store reviewers can't receive +one. Apple often rejects an app its reviewers can't sign in to. + +App Review access gives reviewers a test phone number and a sign-in code that you +enter in App Store Connect and Google Play Console. Nobody is sent a text message. + +## Turn it on + +1. Open **Storefront → Settings → App Review**. For a network, open the network's + **Settings** and go to **App Review**. +2. Choose **Turn on**. A six-digit sign-in code is created. +3. Copy the code straight away. It is only shown once. +4. Enter the phone number and the code in each store: + - **App Store Connect:** your app's version page, under **App Review + Information → Sign-in required**. Enter the phone number as the user name and + the code as the password, and add a note such as "Sign in with this phone + number, then enter this code when asked." + - **Google Play Console:** **App content → App access**, as a set of + instructions with the phone number and the code. + +The phone number is **+1 202 555 0143**. It belongs to a range kept for films and +books, so it can't reach a real person, and it is never sent a message. It is the +same for every store: the code decides which store it signs in to, and a code from +one store doesn't work at another. + +## What reviewers see + +The reviewer signs in like a customer: they enter the phone number, the app asks for +a code, and they enter yours. They can sign up, browse, add the number at checkout, +place orders and delete the account, all without a text message. + +## Orders from reviewers + +Orders a reviewer places are real orders in your store, so you can see what they +tested. They are marked **App Review order** at the top of the order, and staff +alerts about them say they are App Review test orders. They are never sent to a +driver automatically, even if your store dispatches orders automatically. + +Don't prepare or deliver them. Cancel them when you're ready. + +## Create a new code or turn it off + +- **Create new code** replaces the code. The old one stops working at once, so enter + the new one in App Store Connect and Google Play Console before your next + submission. +- **Turn off** stops reviewers signing in. Turn it off once your app is approved if + you like, and turn it back on before you submit an update. Turning it back on + restores the same code, unless you created a new one. + +Only staff with the **manage-app-review** permission on stores (or networks) can +change it; administrators always can. + +## Self-hosted installs + +Self-hosted installs can still set review accounts for the whole instance with +environment variables. They work alongside each store's setting, and are used only +for the identities they list: + +``` +STOREFRONT_BYPASS_VERIFICATION_CODE= +STOREFRONT_REVIEW_ACCOUNTS=reviewer@example.com,+12025550143 +``` + +Both are required. The code works only for the listed phone numbers and email +addresses, in every store on the instance, and they are never sent a code either. + +## For developers + +The Console uses two internal endpoints, on stores and networks alike. Both take the +store's or network's id or public id, and are scoped to the signed-in company. + +``` +GET /storefront/int/v1/stores/{id}/app-review +PUT /storefront/int/v1/stores/{id}/app-review +GET /storefront/int/v1/networks/{id}/app-review +PUT /storefront/int/v1/networks/{id}/app-review +``` + +`PUT` takes `enabled` (true or false) and `regenerate_code` (true to replace the +code), both optional. Turning access on when there is no code creates one. Both +return: + +```json +{ + "app_review": { + "enabled": true, + "phone": "+12025550143", + "phone_display": "+1 202 555 0143", + "has_code": true, + "code_generated_at": "2026-10-24T09:12:00+00:00", + "enabled_at": "2026-10-24T09:12:00+00:00", + "code": "482913" + } +} +``` + +`code` is present only in the response that created it. The server stores only a +hash of it, so it can't be read back later; create a new one instead. `GET` needs +the `view` permission on the store or network, `PUT` needs `manage-app-review`. diff --git a/extension.json b/extension.json index fad9a69b..1b8cf108 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.29", + "version": "0.4.30", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index e56b68ad..132d9043 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.29", + "version": "0.4.30", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", diff --git a/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php b/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php new file mode 100644 index 00000000..7ed2c5d9 --- /dev/null +++ b/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php @@ -0,0 +1,47 @@ +hasTable('app_review_accesses')) { + return; + } + + $schema->create('app_review_accesses', function (Blueprint $table) use ($databaseName) { + $table->bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->foreignUuid('company_uuid')->nullable()->index()->references('uuid')->on(new Expression($databaseName . '.companies'))->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->uuid('owner_uuid')->unique(); + $table->string('owner_type')->nullable(); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->foreignUuid('updated_by_uuid')->nullable()->references('uuid')->on(new Expression($databaseName . '.users'))->onUpdate('CASCADE')->onDelete('SET NULL'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::connection(config('storefront.connection.db'))->dropIfExists('app_review_accesses'); + } +}; diff --git a/server/src/Auth/Schemas/Storefront.php b/server/src/Auth/Schemas/Storefront.php index 2da1ccff..bf2f6618 100644 --- a/server/src/Auth/Schemas/Storefront.php +++ b/server/src/Auth/Schemas/Storefront.php @@ -73,7 +73,7 @@ class Storefront ], [ 'name' => 'network', - 'actions' => ['rotate-key'], + 'actions' => ['rotate-key', 'manage-app-review'], ], [ 'name' => 'network-store', @@ -85,7 +85,7 @@ class Storefront ], [ 'name' => 'store', - 'actions' => ['rotate-key'], + 'actions' => ['rotate-key', 'manage-app-review'], ], [ 'name' => 'store-location', diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index 2012f796..ab0c0ec6 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -12,6 +12,7 @@ use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\NetworkStore; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Traits\ManagesAppReviewAccess; use Fleetbase\Support\ApiModelCache; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -20,6 +21,8 @@ class NetworkController extends StorefrontController { + use ManagesAppReviewAccess; + /** * The resource to query. * @@ -69,6 +72,13 @@ public function rotateKey(string $id, Request $request) return response()->json(['status' => 'ok', 'key' => $network->rotateKey()]); } + protected function appReviewOwner(string $id, Request $request): Store|Network|null + { + return Network::where('company_uuid', $request->session()->get('company')) + ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) + ->first(); + } + /** * Add stores to a network. * diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 56f508c6..5967dbd1 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -2,12 +2,16 @@ namespace Fleetbase\Storefront\Http\Controllers; +use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Traits\ManagesAppReviewAccess; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class StoreController extends StorefrontController { + use ManagesAppReviewAccess; + /** * The resource to query. * @@ -54,4 +58,11 @@ public function rotateKey(string $id, Request $request) return response()->json(['status' => 'ok', 'key' => $store->rotateKey()]); } + + protected function appReviewOwner(string $id, Request $request): Store|Network|null + { + return Store::where('company_uuid', $request->session()->get('company')) + ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) + ->first(); + } } diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 3359745a..bac42521 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -31,6 +31,7 @@ use Fleetbase\Storefront\Promotions\PromotionRedemptions; use Fleetbase\Storefront\Promotions\PromotionResult; use Fleetbase\Storefront\Promotions\PromotionUnavailableException; +use Fleetbase\Storefront\Support\AppReview; use Fleetbase\Storefront\Support\BookingSlots; use Fleetbase\Storefront\Support\Inventory; use Fleetbase\Storefront\Support\OrderPayments; @@ -1631,6 +1632,9 @@ public function captureOrder(CaptureOrderRequest $request) ...$transactionDetails, ]); + // an app reviewer's order is a test the store should not fulfil + $orderMeta = AppReview::markOrderMeta($orderMeta, $customer); + // Create order input here $orderInput = []; @@ -2047,6 +2051,9 @@ public function captureMultipleOrders(CaptureOrderRequest $request) // A booked service makes this store's order a booking order, with its own flow. [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); + // an app reviewer's order is a test the store should not fulfil + $orderMeta = AppReview::markOrderMeta($orderMeta, $customer); + // set meta to order input last $orderInput['meta'] = $orderMeta; @@ -2139,7 +2146,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) } // finally apply meta to master order - $masterOrderInput['meta'] = $masterOrderMeta; + $masterOrderInput['meta'] = AppReview::markOrderMeta($masterOrderMeta, $customer); // create master order $order = Order::create($masterOrderInput); diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 96dfb90c..155bcfde 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -21,7 +21,10 @@ use Fleetbase\Storefront\Http\Requests\CreateCustomerRequest; use Fleetbase\Storefront\Http\Requests\VerifyCreateCustomerRequest; use Fleetbase\Storefront\Http\Resources\Customer; +use Fleetbase\Storefront\Models\Network; +use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Push\StorefrontPushChannel; +use Fleetbase\Storefront\Support\AppReview; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Support\SocketCluster\SocketToken; @@ -258,6 +261,13 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request $identity = static::phone($identity); } + // An app reviewer is sent nothing: they sign up with the store's App Review code. The + // review account is shared by every store, so it may already exist; create() then + // gives it a customer in this store. + if ($reviewerResponse = static::reviewerSendResponse($identity, $about, ['status' => 'ok'])) { + return $reviewerResponse; + } + // An identity that already has an account cannot ask for a creation code. Nothing // is checked or sent; the caller is told to sign in instead. if (static::accountExistsForIdentity($identity, $isEmail)) { @@ -338,18 +348,20 @@ public function create(CreateCustomerRequest $request) return response()->apiError('An identity is required to create a customer.'); } - if ($this->codeAttemptsExceeded('create|' . $identity)) { + $attemptKey = 'create|' . static::attemptIdentity($identity, $about); + if ($this->codeAttemptsExceeded($attemptKey)) { return response()->apiError('Too many attempts. Try again later.', 429); } - // verify code: the newest live code for this identity, checked and used up - if (!$this->verifyIdentityCode('storefront_create_customer', $identity, $code)) { - $this->recordFailedCodeAttempt('create|' . $identity); + // verify code: an app reviewer's App Review code, else the newest live code for this + // identity, checked and used up + if (!static::acceptsReviewerCode($identity, $code, $about) && !$this->verifyIdentityCode('storefront_create_customer', $identity, $code)) { + $this->recordFailedCodeAttempt($attemptKey); return response()->apiError('Invalid verification code provided!'); } - $this->clearCodeAttempts('create|' . $identity); + $this->clearCodeAttempts($attemptKey); // A phone sign-up's texted code has just proved the number, so it is the account's // phone; without this the number only lived in the code and the app asked for it again. @@ -673,6 +685,15 @@ public function loginWithPhone() return response()->apiError('No customer with this phone # found.'); } + // get the storefront or network logging in for + $about = Storefront::about(); + + // An app reviewer is sent nothing: they sign in with the store's App Review code, and + // verifyCode() creates the review account on its first sign-in. + if ($reviewerResponse = static::reviewerSendResponse($phone, $about, ['status' => 'OK', 'method' => 'sms'])) { + return $reviewerResponse; + } + // check if user exists $user = User::where('phone', $phone)->whereNull('deleted_at')->withoutGlobalScopes()->first(); @@ -680,9 +701,6 @@ public function loginWithPhone() return response()->apiError('No customer with this phone # found.'); } - // get the storefront or network logging in for - $about = Storefront::about(); - // Generate the verification token. // // The SMS attempt is guarded because the Twilio SDK THROWS when the store has no @@ -1024,27 +1042,20 @@ protected function createCustomerRequestFrom(Request $request): CreateCustomerRe * only honoured for an identity explicitly listed in storefront.review_accounts, * and both the code and the list must be configured. * + * This is the instance-wide (env) fallback for self-hosted installs; a store's own + * App Review access is checked by AppReview::acceptsCode(). + * * @param string|null $identity email or phone the caller is authenticating as * @param mixed $code the submitted verification code */ protected static function isReviewAccountBypass(?string $identity, $code): bool { - $bypassCode = config('storefront.storefront_app.bypass_verification_code'); - if (blank($bypassCode) || blank($code) || blank($identity)) { - return false; - } - - $reviewAccounts = array_map( - static fn ($account) => strtolower(trim((string) $account)), - (array) config('storefront.storefront_app.review_accounts', []) - ); - - if (!in_array(strtolower(trim($identity)), $reviewAccounts, true)) { + if (blank($code) || !AppReview::isConfiguredReviewAccount($identity)) { return false; } // hash_equals so a wrong code cannot be recovered by timing the response. - if (!hash_equals((string) $bypassCode, (string) $code)) { + if (!hash_equals((string) config('storefront.storefront_app.bypass_verification_code'), (string) $code)) { return false; } @@ -1055,6 +1066,44 @@ protected static function isReviewAccountBypass(?string $identity, $code): bool return true; } + /** + * Whether a code signs an app reviewer in: this storefront's App Review code for the + * review number, or the instance-wide bypass for a configured review account. + */ + protected static function acceptsReviewerCode(?string $identity, mixed $code, Store|Network|null $about): bool + { + return AppReview::acceptsCode($about, $identity, $code) || static::isReviewAccountBypass($identity, $code); + } + + /** + * The answer to a request to send a code to an app reviewer, or null for anyone else. + * Nothing is ever sent to a reviewer: they sign in with the code the store gave the app + * store. The review number is refused outright where its access is off, so it is never + * texted either. + */ + protected static function reviewerSendResponse(?string $identity, Store|Network|null $about, array $ok) + { + if (AppReview::isConfiguredReviewAccount($identity)) { + return response()->json($ok); + } + + if (AppReview::isReviewPhone($identity)) { + return AppReview::isEnabledFor($about) ? response()->json($ok) : response()->apiError(AppReview::OFF_MESSAGE, 422); + } + + return null; + } + + /** + * The key wrong codes are counted under. The review number is shared by every store, so + * its count is kept per storefront: guessing at one store cannot lock reviewers out of + * another. + */ + protected static function attemptIdentity(string $identity, Store|Network|null $about): string + { + return AppReview::isReviewPhone($identity) && $about ? $identity . '|' . $about->uuid : $identity; + } + public function verifyCode(Request $request) { $identity = Utils::isEmail($request->identity) ? $request->identity : static::phone($request->identity); @@ -1073,31 +1122,35 @@ public function verifyCode(Request $request) return response()->apiError('Unable to verify code.'); } - if ($this->codeAttemptsExceeded('verify|' . $identity)) { + // get the storefront or network logging in for + $about = Storefront::about(['company_uuid']); + + $attemptKey = 'verify|' . static::attemptIdentity($identity, $about); + if ($this->codeAttemptsExceeded($attemptKey)) { return response()->apiError('Too many attempts. Try again later.', 429); } + // This storefront's App Review code signs the review number in, creating the shared + // review account the first time. + $isAppReviewer = AppReview::acceptsCode($about, $identity, $code); + // check if user exists - $user = User::where('phone', $identity)->orWhere('email', $identity)->first(); + $user = $isAppReviewer ? AppReview::user($about) : User::where('phone', $identity)->orWhere('email', $identity)->first(); if (!$user) { - $this->recordFailedCodeAttempt('verify|' . $identity); + $this->recordFailedCodeAttempt($attemptKey); return response()->apiError('Unable to verify code.'); } // find and verify code: the newest live code for this user, checked and used up - $verificationCode = $this->verifySubjectCode($for, $user->uuid, $code); - if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) { - $this->recordFailedCodeAttempt('verify|' . $identity); + if (!$isAppReviewer && !$this->verifySubjectCode($for, $user->uuid, $code) && !static::isReviewAccountBypass($identity, $code)) { + $this->recordFailedCodeAttempt($attemptKey); return response()->apiError('Invalid verification code!'); } - $this->clearCodeAttempts('verify|' . $identity); - - // get the storefront or network logging in for - $about = Storefront::about(['company_uuid']); + $this->clearCodeAttempts($attemptKey); // get contact record $contact = Contact::firstOrCreate( @@ -1420,6 +1473,11 @@ public function startAccountClosure(Request $request) return "Your {$about->name} account closure verification code is {$verification->code}"; }; + // An app reviewer confirms with the store's App Review code; nothing is sent. + if ($reviewerResponse = static::reviewerSendResponse($identity, $about, ['status' => 'OK'])) { + return $reviewerResponse; + } + // The SMS attempt is guarded rather than sharing one try with the email branch. // The Twilio SDK throws when the store has no credentials configured, and the old // `if phone / elseif email` meant a customer WITH a phone never reached the email @@ -1494,8 +1552,8 @@ public function confirmAccountClosure(Request $request) } // verify account closure code - $verificationCode = $this->verifyIdentityCode('storefront_account_closure', $identity, $code); - if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) { + $isAppReviewer = AppReview::acceptsCode($about, $identity, $code); + if (!$isAppReviewer && !$this->verifyIdentityCode('storefront_account_closure', $identity, $code) && !static::isReviewAccountBypass($identity, $code)) { $this->recordFailedCodeAttempt('closure|' . $customer->uuid); return response()->apiError('Invalid verification code provided!'); @@ -1504,8 +1562,10 @@ public function confirmAccountClosure(Request $request) $this->clearCodeAttempts('closure|' . $customer->uuid); try { - // If the user type is `contact` or `customer` delete the user account - if ($user->isType(['contact', 'customer'])) { + // If the user type is `contact` or `customer` delete the user account. The review + // account is shared by every store, so closing it only removes this store's + // customer; it signs in afresh next time. + if ($user->isType(['contact', 'customer']) && !AppReview::isReviewPhone($user->phone)) { $user->delete(); } @@ -1546,21 +1606,23 @@ public function requestPhoneVerification(Request $request) return response()->apiError('A phone number is required to request verification.'); } - // Check if phone number is already used by another user - $existingUser = $this->findExistingUserByPhone($phone, $user->uuid); + $about = Storefront::about(); + + // Check if phone number is already used by another user. The review number is the + // exception: it belongs to the shared review account, and a reviewer signed in another + // way (Apple, Google) may still be asked for it at checkout. + $existingUser = AppReview::isReviewPhone($phone) ? null : $this->findExistingUserByPhone($phone, $user->uuid); if ($existingUser) { return response()->apiError('This phone number is already associated with another account.'); } - $about = Storefront::about(); - - // A review account verifies with the configured bypass code, so no message needs - // to be delivered — and requiring one would make the flow untestable wherever SMS - // is not configured, which is the situation this bypass exists for. Same - // allowlist + constant-time code check as every other bypass call site. - if (static::isReviewAccountBypass($phone, config('storefront.storefront_app.bypass_verification_code'))) { - return response()->json(['status' => 'ok', 'method' => 'bypass']); + // An app reviewer verifies with the store's App Review code (or a self-hosted review + // account with the configured bypass code), so no message needs to be delivered — + // and requiring one would make the flow untestable wherever SMS is not configured, + // which is the situation review access exists for. + if ($reviewerResponse = static::reviewerSendResponse($phone, $about, ['status' => 'ok', 'method' => 'bypass'])) { + return $reviewerResponse; } // A number that can never receive a text is refused before anything is written. @@ -1638,7 +1700,7 @@ public function verifyPhoneNumber(Request $request) $requestedPhone = $request->input('phone') ? static::phone($request->input('phone')) : null; if (!$verificationCode) { - if ($requestedPhone && static::isReviewAccountBypass($requestedPhone, $code)) { + if ($requestedPhone && static::acceptsReviewerCode($requestedPhone, $code, Storefront::about())) { $user->update(['phone' => $requestedPhone, 'phone_verified_at' => now()]); $customer->update(['phone' => $requestedPhone]); diff --git a/server/src/Models/AppReviewAccess.php b/server/src/Models/AppReviewAccess.php new file mode 100644 index 00000000..213312a7 --- /dev/null +++ b/server/src/Models/AppReviewAccess.php @@ -0,0 +1,70 @@ + 'boolean', + 'code_generated_at' => 'datetime', + 'enabled_at' => 'datetime', + ]; + + public function owner() + { + return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid'); + } + + /** + * Turned on and holding a code: the only state in which a reviewer can sign in. + */ + public function isUsable(): bool + { + return $this->enabled && filled($this->code); + } + + public function hasCode(): bool + { + return filled($this->code); + } + + /** + * Replace the code with the given one, stored hashed. + */ + public function setPlainCode(string $code): void + { + $this->code = Hash::make($code); + $this->code_generated_at = now(); + } + + public function checkCode(string $code): bool + { + return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); + } +} diff --git a/server/src/Notifications/StorefrontOrderCreated.php b/server/src/Notifications/StorefrontOrderCreated.php index 22f38780..6f659261 100644 --- a/server/src/Notifications/StorefrontOrderCreated.php +++ b/server/src/Notifications/StorefrontOrderCreated.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\AppReview; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; use Illuminate\Bus\Queueable; @@ -107,6 +108,10 @@ public function toTwilio($notifiable) 'Customer: ' . $customerName . ' (' . $customerPhone . ')' . "\n" . 'Items: ' . $items . "\n"; + if (AppReview::isReviewOrder($this->order)) { + $content .= AppReview::ORDER_NOTICE . "\n"; + } + if ($isDelivery) { $content .= 'Address: ' . $deliveryAddress . "\n"; $content .= 'Delivery Fee: ' . Utils::moneyFormat($deliveryFee, $currency) . "\n"; @@ -157,6 +162,10 @@ public function toMail($notifiable) ->line('Customer: ' . $customerName . ' (' . $customerPhone . ')') ->line('Items: ' . $items); + if (AppReview::isReviewOrder($this->order)) { + $message->line(AppReview::ORDER_NOTICE); + } + if ($isDelivery) { $message->line('Address: ' . $deliveryAddress); $message->line('Delivery Fee: ' . Utils::moneyFormat($deliveryFee, $currency)); diff --git a/server/src/Support/AppReview.php b/server/src/Support/AppReview.php new file mode 100644 index 00000000..a6cda503 --- /dev/null +++ b/server/src/Support/AppReview.php @@ -0,0 +1,258 @@ +uuid)) { + return null; + } + + try { + return AppReviewAccess::where('owner_uuid', $owner->uuid)->first(); + } catch (\Throwable $e) { + // Before the migration has run there is no access, not an error. + return null; + } + } + + public static function isEnabledFor(Store|Network|null $owner): bool + { + return (bool) static::accessFor($owner)?->isUsable(); + } + + /** + * Whether a code signs the review number in to this storefront: its access is on and the + * code matches the one it generated. Another store's code never matches. + */ + public static function acceptsCode(Store|Network|null $owner, ?string $identity, mixed $code): bool + { + if (!static::isReviewPhone($identity) || blank($code) || !is_scalar($code)) { + return false; + } + + $access = static::accessFor($owner); + if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { + return false; + } + + Log::info('[Storefront] App Review sign-in accepted.', [ + 'storefront' => $owner->public_id ?? $owner->uuid, + ]); + + return true; + } + + /** + * Whether an identity is listed as an instance-wide review account (self-hosted env + * config) and a bypass code is configured for it. + */ + public static function isConfiguredReviewAccount(?string $identity): bool + { + if (blank($identity) || blank(config('storefront.storefront_app.bypass_verification_code'))) { + return false; + } + + $reviewAccounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config('storefront.storefront_app.review_accounts', []) + ); + + return in_array(strtolower(trim($identity)), $reviewAccounts, true); + } + + /** + * Whether a customer is an app reviewer: signed in as the review number, or as an + * instance-wide review account. + */ + public static function isReviewCustomer(mixed $customer): bool + { + if (!$customer instanceof Contact) { + return false; + } + + $identities = [$customer->phone, $customer->email]; + + try { + $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->withoutGlobalScopes()->first() : null; + } catch (\Throwable $e) { + $user = null; + } + + if ($user) { + $identities[] = $user->phone; + $identities[] = $user->email; + } + + foreach (array_filter($identities) as $identity) { + if (static::isReviewPhone($identity) || static::isConfiguredReviewAccount($identity)) { + return true; + } + } + + return false; + } + + /** + * Order meta with the App Review mark added when a reviewer placed the order, so the + * store can see in the Console that it is a test and not fulfil it. + */ + public static function markOrderMeta(array $meta, mixed $customer): array + { + if (static::isReviewCustomer($customer)) { + $meta['app_review'] = true; + } + + return $meta; + } + + public static function isReviewOrder(?Order $order): bool + { + return $order !== null && $order->getMeta('app_review') === true; + } + + /** + * The shared review account, created on its first sign-in. It is a customer user like + * any other; each store it signs in to gets its own customer contact. + */ + public static function user(Store|Network $owner): User + { + $user = User::where('phone', static::PHONE)->whereNull('deleted_at')->withoutGlobalScopes()->first(); + if ($user) { + return $user; + } + + $user = User::create([ + 'company_uuid' => $owner->company_uuid, + 'name' => static::ACCOUNT_NAME, + 'phone' => static::PHONE, + 'phone_verified_at' => now(), + ]); + $user->setUserType('customer'); + + return $user; + } + + /** + * A new random code. + */ + public static function generateCode(): string + { + return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Change a storefront's access. Turning it on without a code generates one, so it is + * never on with nothing to sign in with; `regenerate` replaces the code. Turning it off + * keeps the code, so turning it back on restores the same one. + * + * @return array{0: AppReviewAccess, 1: ?string} the access and the new plain code, if one was generated + */ + public static function update(Store|Network $owner, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array + { + $access = static::accessFor($owner) ?? new AppReviewAccess([ + 'company_uuid' => $owner->company_uuid, + 'owner_uuid' => $owner->uuid, + 'owner_type' => get_class($owner), + 'enabled' => false, + ]); + + if ($enabled !== null) { + if ($enabled && !$access->enabled) { + $access->enabled_at = now(); + } + $access->enabled = $enabled; + } + + $code = null; + if ($regenerate || ($access->enabled && !$access->hasCode())) { + $code = static::generateCode(); + $access->setPlainCode($code); + } + + $access->updated_by_uuid = $updatedByUuid; + $access->save(); + + return [$access, $code]; + } + + /** + * What the Console and the internal API show about a storefront's access. Never the code + * or its hash; `code` is added by the caller only in the response that generated it. + */ + public static function status(?AppReviewAccess $access): array + { + return [ + 'enabled' => (bool) $access?->enabled, + 'phone' => static::PHONE, + 'phone_display' => static::PHONE_DISPLAY, + 'has_code' => (bool) $access?->hasCode(), + 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), + 'enabled_at' => $access?->enabled_at?->toIso8601String(), + ]; + } +} diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 97ca746e..47d09314 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -789,6 +789,11 @@ public static function autoAcceptOrder(Order $order) public static function autoDispatchOrder(Order $order, bool $adhoc = true) { + // An app store reviewer's order is a test: never send a driver for it. + if (AppReview::isReviewOrder($order)) { + return $order; + } + // Patch order config Storefront::patchOrderConfig($order); diff --git a/server/src/Traits/ManagesAppReviewAccess.php b/server/src/Traits/ManagesAppReviewAccess.php new file mode 100644 index 00000000..6f36d0a2 --- /dev/null +++ b/server/src/Traits/ManagesAppReviewAccess.php @@ -0,0 +1,57 @@ +appReviewOwner($id, $request); + if (!$owner) { + return response()->error('Storefront not found.', 404); + } + + return response()->json(['app_review' => AppReview::status(AppReview::accessFor($owner))]); + } + + public function manageAppReview(string $id, Request $request) + { + $owner = $this->appReviewOwner($id, $request); + if (!$owner) { + return response()->error('Storefront not found.', 404); + } + + $request->validate([ + 'enabled' => 'sometimes|boolean', + 'regenerate_code' => 'sometimes|boolean', + ]); + + $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; + + [$access, $code] = AppReview::update($owner, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); + + $status = AppReview::status($access); + if ($code !== null) { + $status['code'] = $code; + } + + return response()->json(['app_review' => $status]); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 353fde5d..bd527d23 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -243,6 +243,8 @@ function ($router, $controller) { 'networks', function ($router, $controller) { $router->post('{id}/rotate-key', $controller('rotateKey')); + $router->get('{id}/app-review', $controller('appReview')); + $router->put('{id}/app-review', $controller('manageAppReview')); $router->delete('{id}/remove-category', $controller('deleteCategory')); $router->post('{id}/set-store-category', $controller('addStoreToCategory')); $router->post('{id}/remove-store-category', $controller('removeStoreCategory')); @@ -274,6 +276,8 @@ function ($router, $controller) { 'stores', function ($router, $controller) { $router->post('{id}/rotate-key', $controller('rotateKey')); + $router->get('{id}/app-review', $controller('appReview')); + $router->put('{id}/app-review', $controller('manageAppReview')); } ); $router->fleetbaseRoutes('store-hours'); diff --git a/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php new file mode 100644 index 00000000..bf5295c2 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php @@ -0,0 +1,518 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['app_review_accesses', 'personal_access_tokens', 'verification_codes', 'contacts', 'users', 'stores', 'networks', 'companies'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach ([ + 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', + 'order_config_uuid', 'key', 'name', 'description', 'translations', + 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', + 'tags', 'currency', 'timezone', 'pod_method', 'options', 'slug', + ] as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->softDeletes(); + }); + } + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->text('options')->nullable(); + $table->softDeletes(); + }); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('phone_verified_at')->nullable(); + $table->string('password')->nullable(); + $table->string('type')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + $table->text('meta')->nullable(); + $table->string('photo_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('verification_codes', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('app_review_accesses', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('owner_uuid')->unique(); + $table->string('owner_type')->nullable(); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->string('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company_a', 'options' => '{}'], + ['uuid' => 'company_b', 'options' => '{}'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store_a_uuid', 'public_id' => 'store_a', 'company_uuid' => 'company_a', 'key' => 'store_a_key', 'name' => 'Corner Store'], + ['uuid' => 'store_b_uuid', 'public_id' => 'store_b', 'company_uuid' => 'company_b', 'key' => 'store_b_key', 'name' => 'Night Bakery'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network_uuid', 'public_id' => 'network_public', 'company_uuid' => 'company_a', 'key' => 'network_key', 'name' => 'Night Market'], + ]); + + // Boot the models without a dispatcher, so their traits (activity log and the like) register + // nothing, then listen only for what the tests need. + Model::unsetEventDispatcher(); + Model::clearBootedModels(); + foreach ([User::class, Contact::class, AppReviewAccess::class, Store::class, Network::class] as $class) { + new $class(); + } + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + User::creating(fn ($user) => $user->uuid ??= (string) Str::uuid()); + Contact::creating(fn ($contact) => $contact->uuid ??= (string) Str::uuid()); + AppReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + // Anything that would deliver a code is recorded, so a test can prove nothing went out. + $sms = new class { + public array $sent = []; + + public function message(string $to, string $message, array $media = [], array $params = []): object + { + $this->sent[] = $to; + + return (object) ['sid' => 'sms_test']; + } + }; + app()->instance('twilio', $sms); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + + config([ + 'storefront.storefront_app.bypass_verification_code' => null, + 'storefront.storefront_app.review_accounts' => [], + ]); + + return $sms; +} + +/** + * A storefront app request for the given store key. + */ +function appReviewRequest(string $storefrontKey, array $input = [], string $class = Request::class, ?string $customerToken = null): Request +{ + $request = $class::create('/storefront/v1/customers', 'POST', $input); + $request->setLaravelSession(new SessionStore('app-review-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + + $company = Model::getConnectionResolver()->connection('mysql')->table(str_starts_with($storefrontKey, 'network') ? 'networks' : 'stores')->where('key', $storefrontKey)->value('company_uuid'); + session(['storefront_key' => $storefrontKey, 'company' => $company]); + + return $request; +} + +function turnOnAppReview(string $storeUuid): string +{ + [, $code] = AppReview::update(Store::where('uuid', $storeUuid)->first(), true); + + return $code; +} + +function appReviewUserCount(): int +{ + return Model::getConnectionResolver()->connection('mysql')->table('users')->where('phone', AppReview::PHONE)->whereNull('deleted_at')->count(); +} + +afterEach(function () { + Model::unsetEventDispatcher(); + config([ + 'storefront.storefront_app.bypass_verification_code' => null, + 'storefront.storefront_app.review_accounts' => [], + ]); +}); + +test('the review number is a fictional number that the phone check accepts', function () { + $util = libphonenumber\PhoneNumberUtil::getInstance(); + $parsed = $util->parse(AppReview::PHONE, null); + + expect(AppReview::PHONE)->toMatch('/^\+1\d{3}55501\d{2}$/') + ->and($util->isValidNumber($parsed))->toBeTrue() + ->and($util->getRegionCodeForNumber($parsed))->toBe('US') + // What the app accepts before it sends a number (src/utils isValidPhoneNumber). + ->and(preg_match('/^\+\d{8,15}$/', AppReview::PHONE))->toBe(1) + ->and(AppReview::isReviewPhone('+1 (202) 555-0143'))->toBeTrue() + ->and(AppReview::isReviewPhone('12025550143'))->toBeTrue() + ->and(AppReview::isReviewPhone('+12025550144'))->toBeFalse() + ->and(AppReview::isReviewPhone(null))->toBeFalse(); +}); + +test('with App Review access on, the review number signs in with the store code and is never sent anything', function () { + $sms = createAppReviewSchema(); + $code = turnOnAppReview('store_a_uuid'); + + $sent = (new CustomerController())->loginWithPhone(appReviewRequest('store_a_key', ['phone' => AppReview::PHONE])); + expect($sent->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']); + + $customer = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + + expect($customer)->toBeInstanceOf(Customer::class) + ->and($customer->resource->company_uuid)->toBe('company_a') + ->and($customer->resource->token)->not->toBeEmpty() + ->and(appReviewUserCount())->toBe(1) + ->and(User::where('phone', AppReview::PHONE)->first()->phone_verified_at)->not->toBeNull() + ->and(Model::getConnectionResolver()->connection('mysql')->table('verification_codes')->count())->toBe(0) + ->and($sms->sent)->toBe([]); + + // Signing in again reuses the account and the store's customer. + (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + expect(appReviewUserCount())->toBe(1) + ->and(Contact::where('company_uuid', 'company_a')->count())->toBe(1); +}); + +test('with App Review access off, the review number is refused and not texted', function () { + $sms = createAppReviewSchema(); + + $sent = (new CustomerController())->loginWithPhone(appReviewRequest('store_a_key', ['phone' => AppReview::PHONE])); + expect($sent->getStatusCode())->toBe(422) + ->and($sent->getData(true))->toBe(['error' => AppReview::OFF_MESSAGE]); + + $creation = (new CustomerController())->requestCustomerCreationCode(appReviewRequest('store_a_key', ['mode' => 'sms', 'identity' => AppReview::PHONE], VerifyCreateCustomerRequest::class)); + expect($creation->getStatusCode())->toBe(422); + + // A store that turned it on and then off: its code stops working. + $code = turnOnAppReview('store_a_uuid'); + AppReview::update(Store::where('uuid', 'store_a_uuid')->first(), false); + + $refused = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + + expect($refused->getData(true))->toBe(['error' => 'Unable to verify code.']) + ->and(appReviewUserCount())->toBe(0) + ->and($sms->sent)->toBe([]); +}); + +test('a wrong code does not sign the review number in', function () { + createAppReviewSchema(); + $code = turnOnAppReview('store_a_uuid'); + $wrong = $code === '000000' ? '000001' : '000000'; + + $refused = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $wrong])); + expect($refused->getData(true))->toBe(['error' => 'Unable to verify code.']) + ->and(appReviewUserCount())->toBe(0); + + // Once the account exists (signed in properly), a wrong code is still refused. + (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + $refusedAgain = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $wrong])); + expect($refusedAgain->getData(true))->toBe(['error' => 'Invalid verification code!']); +}); + +test('one store code does not sign the review number in to another store', function () { + createAppReviewSchema(); + $codeA = turnOnAppReview('store_a_uuid'); + do { + $codeB = turnOnAppReview('store_b_uuid'); + } while ($codeB === $codeA); + + $crossA = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $codeB])); + expect($crossA->getData(true))->toBe(['error' => 'Unable to verify code.']); + + $customerB = (new CustomerController())->verifyCode(appReviewRequest('store_b_key', ['identity' => AppReview::PHONE, 'code' => $codeB])); + expect($customerB)->toBeInstanceOf(Customer::class) + ->and($customerB->resource->company_uuid)->toBe('company_b'); + + // Store A's code still only works at store A, now that the shared account exists. + $crossB = (new CustomerController())->verifyCode(appReviewRequest('store_b_key', ['identity' => AppReview::PHONE, 'code' => $codeA])); + expect($crossB->getData(true))->toBe(['error' => 'Invalid verification code!']); + + $customerA = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $codeA])); + expect($customerA)->toBeInstanceOf(Customer::class) + ->and($customerA->resource->company_uuid)->toBe('company_a') + ->and(appReviewUserCount())->toBe(1); +}); + +test('a network has its own App Review access', function () { + createAppReviewSchema(); + [, $code] = AppReview::update(Network::where('uuid', 'network_uuid')->first(), true); + + $customer = (new CustomerController())->verifyCode(appReviewRequest('network_key', ['identity' => AppReview::PHONE, 'code' => $code])); + $atStore = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + + expect($customer)->toBeInstanceOf(Customer::class) + ->and($atStore->getData(true))->toBe(['error' => 'Invalid verification code!']); +}); + +test('the instance-wide review accounts still work where the store has not turned access on', function () { + $sms = createAppReviewSchema(); + config([ + 'storefront.storefront_app.bypass_verification_code' => '135790', + 'storefront.storefront_app.review_accounts' => [AppReview::PHONE, '+97699112233'], + ]); + Model::getConnectionResolver()->connection('mysql')->table('users')->insert([ + 'uuid' => 'self_hosted_reviewer', 'phone' => '+97699112233', 'type' => 'customer', 'created_at' => now(), 'updated_at' => now(), + ]); + + // The review number itself, through the env fallback: the account already exists here. + Model::getConnectionResolver()->connection('mysql')->table('users')->insert([ + 'uuid' => 'review_number_user', 'phone' => AppReview::PHONE, 'type' => 'customer', 'created_at' => now(), 'updated_at' => now(), + ]); + $sent = (new CustomerController())->loginWithPhone(appReviewRequest('store_a_key', ['phone' => AppReview::PHONE])); + $viaEnv = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => '135790'])); + $listed = (new CustomerController())->loginWithPhone(appReviewRequest('store_a_key', ['phone' => '+97699112233'])); + $viaList = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => '+97699112233', 'code' => '135790'])); + $wrong = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => '+97699112233', 'code' => '135791'])); + + expect($sent->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) + ->and($viaEnv)->toBeInstanceOf(Customer::class) + ->and($listed->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) + ->and($viaList)->toBeInstanceOf(Customer::class) + ->and($wrong->getData(true))->toBe(['error' => 'Invalid verification code!']) + ->and($sms->sent)->toBe([]); +}); + +test('signing up as the review number uses the store code and joins the shared account', function () { + $sms = createAppReviewSchema(); + $code = turnOnAppReview('store_a_uuid'); + + $requested = (new CustomerController())->requestCustomerCreationCode(appReviewRequest('store_a_key', ['mode' => 'sms', 'identity' => AppReview::PHONE], VerifyCreateCustomerRequest::class)); + expect($requested->getData(true))->toBe(['status' => 'ok']); + + $wrong = (new CustomerController())->create(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'name' => 'Reviewer', 'code' => '12'], CreateCustomerRequest::class)); + expect($wrong->getData(true))->toBe(['error' => 'Invalid verification code provided!']); + + $created = (new CustomerController())->create(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'name' => 'Reviewer', 'code' => $code], CreateCustomerRequest::class)); + + expect($created)->toBeInstanceOf(Customer::class) + ->and($created->resource->phone)->toBe(AppReview::PHONE) + ->and(appReviewUserCount())->toBe(1) + ->and(Model::getConnectionResolver()->connection('mysql')->table('verification_codes')->count())->toBe(0) + ->and($sms->sent)->toBe([]); + + // The account exists now; signing up from another store with access on still works. + $codeB = turnOnAppReview('store_b_uuid'); + $requested = (new CustomerController())->requestCustomerCreationCode(appReviewRequest('store_b_key', ['mode' => 'sms', 'identity' => AppReview::PHONE], VerifyCreateCustomerRequest::class)); + $createdB = (new CustomerController())->create(appReviewRequest('store_b_key', ['identity' => AppReview::PHONE, 'name' => 'Reviewer', 'code' => $codeB], CreateCustomerRequest::class)); + + expect($requested->getData(true))->toBe(['status' => 'ok']) + ->and($createdB)->toBeInstanceOf(Customer::class) + ->and($createdB->resource->company_uuid)->toBe('company_b') + ->and(appReviewUserCount())->toBe(1); +}); + +test('closing the review account removes only this store customer and keeps the shared account', function () { + $sms = createAppReviewSchema(); + $code = turnOnAppReview('store_a_uuid'); + + $customer = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); + $token = $customer->resource->token; + + $started = (new CustomerController())->startAccountClosure(appReviewRequest('store_a_key', [], Request::class, $token)); + $wrong = (new CustomerController())->confirmAccountClosure(appReviewRequest('store_a_key', ['code' => 'nope'], Request::class, $token)); + $closed = (new CustomerController())->confirmAccountClosure(appReviewRequest('store_a_key', ['code' => $code], Request::class, $token)); + + expect($started->getData(true))->toBe(['status' => 'OK']) + ->and($wrong->getData(true))->toBe(['error' => 'Invalid verification code provided!']) + ->and($closed->getData(true))->toBe(['status' => 'OK']) + ->and(Contact::where('company_uuid', 'company_a')->count())->toBe(0) + ->and(appReviewUserCount())->toBe(1) + ->and($sms->sent)->toBe([]); +}); + +test('a reviewer signed in another way can add the review number with the store code', function () { + createAppReviewSchema(); + $code = turnOnAppReview('store_a_uuid'); + $connection = Model::getConnectionResolver()->connection('mysql'); + // The shared review account already holds the number. + $connection->table('users')->insert(['uuid' => 'review_number_user', 'phone' => AppReview::PHONE, 'type' => 'customer', 'created_at' => now(), 'updated_at' => now()]); + $connection->table('users')->insert(['uuid' => 'apple_reviewer', 'email' => 'reviewer@privaterelay.example', 'type' => 'customer', 'created_at' => now(), 'updated_at' => now()]); + $connection->table('contacts')->insert(['uuid' => '0b7c4ad2-55a1-4b0c-8f44-1e2d3c4b5a69', 'company_uuid' => 'company_a', 'user_uuid' => 'apple_reviewer', 'type' => 'customer', 'created_at' => now(), 'updated_at' => now()]); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => User::class, 'tokenable_id' => 'apple_reviewer', 'name' => '0b7c4ad2-55a1-4b0c-8f44-1e2d3c4b5a69', + 'token' => hash('sha256', 'apple-reviewer-secret'), 'abilities' => '["*"]', 'created_at' => now(), 'updated_at' => now(), + ]); + + $requested = (new CustomerController())->requestPhoneVerification(appReviewRequest('store_a_key', ['phone' => AppReview::PHONE], Request::class, 'apple-reviewer-secret')); + $verified = (new CustomerController())->verifyPhoneNumber(appReviewRequest('store_a_key', ['phone' => AppReview::PHONE, 'code' => $code], Request::class, 'apple-reviewer-secret')); + + expect($requested->getData(true))->toBe(['status' => 'ok', 'method' => 'bypass']) + ->and($verified)->toBeInstanceOf(Customer::class) + ->and($connection->table('users')->where('uuid', 'apple_reviewer')->value('phone'))->toBe(AppReview::PHONE); +}); + +test('orders placed by the review account are marked and never auto-dispatched', function () { + createAppReviewSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('users')->insert(['uuid' => 'review_number_user', 'phone' => AppReview::PHONE, 'type' => 'customer']); + + $reviewer = new Contact(['phone' => AppReview::PHONE]); + $signedInByApple = new Contact(['email' => 'reviewer@privaterelay.example']); + $signedInByApple->user_uuid = 'review_number_user'; + $customer = new Contact(['phone' => '+97699112233']); + + expect(AppReview::markOrderMeta(['total' => 100], $reviewer))->toBe(['total' => 100, 'app_review' => true]) + ->and(AppReview::markOrderMeta(['total' => 100], $signedInByApple))->toBe(['total' => 100, 'app_review' => true]) + ->and(AppReview::markOrderMeta(['total' => 100], $customer))->toBe(['total' => 100]) + ->and(AppReview::markOrderMeta(['total' => 100], null))->toBe(['total' => 100]); + + // A self-hosted review account is a reviewer too. + config([ + 'storefront.storefront_app.bypass_verification_code' => '135790', + 'storefront.storefront_app.review_accounts' => ['+97699112233'], + ]); + expect(AppReview::markOrderMeta([], $customer))->toBe(['app_review' => true]); + + $reviewOrder = new Order(); + $reviewOrder->forceFill(['meta' => ['app_review' => true]])->syncOriginal(); + $order = new Order(); + $order->forceFill(['meta' => ['total' => 100]]); + + expect(AppReview::isReviewOrder($reviewOrder))->toBeTrue() + ->and(AppReview::isReviewOrder($order))->toBeFalse() + // Returned untouched, before the order config is patched or a driver is looked for. + ->and(Fleetbase\Storefront\Support\Storefront::autoDispatchOrder($reviewOrder))->toBe($reviewOrder) + ->and($reviewOrder->isDirty())->toBeFalse(); +}); + +test('the Console turns access on with a code shown once, regenerates it and turns it off', function () { + createAppReviewSchema(); + $request = function (string $company, array $input = [], string $method = 'PUT') { + $request = Request::create('/', $method, $input); + $request->setLaravelSession(new SessionStore('app-review-console', new ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; + }; + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + + $initial = (new StoreController())->appReview('store_a', $request('company_a', [], 'GET'))->getData(true)['app_review']; + expect($initial)->toBe([ + 'enabled' => false, + 'phone' => AppReview::PHONE, + 'phone_display' => AppReview::PHONE_DISPLAY, + 'has_code' => false, + 'code_generated_at' => null, + 'enabled_at' => null, + ]); + + $on = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => true]))->getData(true)['app_review']; + expect($on['enabled'])->toBeTrue() + ->and($on['has_code'])->toBeTrue() + ->and($on['code'])->toMatch('/^\d{6}$/'); + + // Only the hash is stored, and reading the status never returns the code again. + $stored = AppReviewAccess::where('owner_uuid', 'store_a_uuid')->first(); + $status = (new StoreController())->appReview('store_a', $request('company_a', [], 'GET'))->getData(true)['app_review']; + expect($stored->getRawOriginal('code'))->not->toBe($on['code']) + ->and(password_verify($on['code'], $stored->getRawOriginal('code')))->toBeTrue() + ->and($stored->toArray())->not->toHaveKey('code') + ->and($status)->not->toHaveKey('code') + ->and($status['enabled'])->toBeTrue(); + + // Saving again without regenerating keeps the code. + $again = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => true]))->getData(true)['app_review']; + expect($again)->not->toHaveKey('code') + ->and(AppReview::acceptsCode(Store::where('uuid', 'store_a_uuid')->first(), AppReview::PHONE, $on['code']))->toBeTrue(); + + $regenerated = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['regenerate_code' => true]))->getData(true)['app_review']; + $store = Store::where('uuid', 'store_a_uuid')->first(); + expect($regenerated['code'])->toMatch('/^\d{6}$/') + ->and(AppReview::acceptsCode($store, AppReview::PHONE, $regenerated['code']))->toBeTrue() + ->and($regenerated['code'] === $on['code'] || !AppReview::acceptsCode($store, AppReview::PHONE, $on['code']))->toBeTrue(); + + $off = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => false]))->getData(true)['app_review']; + expect($off['enabled'])->toBeFalse() + ->and($off['has_code'])->toBeTrue() + ->and(AppReview::acceptsCode($store, AppReview::PHONE, $regenerated['code']))->toBeFalse(); + + // Another company's store and network are not found. + expect((new StoreController())->manageAppReview('store_b_uuid', $request('company_a', ['enabled' => true]))->getStatusCode())->toBe(404) + ->and((new NetworkController())->appReview('network_public', $request('company_b', [], 'GET'))->getStatusCode())->toBe(404) + ->and((new NetworkController())->manageAppReview('network_public', $request('company_a', ['enabled' => true]))->getData(true)['app_review']['enabled'])->toBeTrue(); +}); + +test('managing App Review access is its own permission on stores and networks', function () { + $resources = collect((new StorefrontSchema())->resources)->keyBy('name'); + + expect($resources['store']['actions'])->toContain('manage-app-review') + ->and($resources['network']['actions'])->toContain('manage-app-review') + ->and(Fleetbase\Support\Utils::slugify('manageAppReview'))->toBe('manage-app-review'); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index fa3540fd..94a1d558 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -107,5 +107,9 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'stores', null], ['POST', '{id}/rotate-key', 'stores:rotateKey'], ['POST', '{id}/rotate-key', 'networks:rotateKey'], + ['GET', '{id}/app-review', 'stores:appReview'], + ['PUT', '{id}/app-review', 'stores:manageAppReview'], + ['GET', '{id}/app-review', 'networks:appReview'], + ['PUT', '{id}/app-review', 'networks:manageAppReview'], )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/tests/integration/components/storefront/app-review-access-test.js b/tests/integration/components/storefront/app-review-access-test.js new file mode 100644 index 00000000..43571c9c --- /dev/null +++ b/tests/integration/components/storefront/app-review-access-test.js @@ -0,0 +1,131 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +class StoreRecord { + static modelName = 'store'; + id = 'store_1'; + name = 'Corner Store'; +} + +class NetworkRecord { + static modelName = 'network'; + id = 'network_1'; + name = 'Night Market'; +} + +const status = (overrides = {}) => ({ + enabled: false, + phone: '+12025550143', + phone_display: '+1 202 555 0143', + has_code: false, + code_generated_at: null, + enabled_at: null, + ...overrides, +}); + +class FetchStubService extends Service { + requests = []; + current = status(); + get(url) { + this.requests.push(['GET', url]); + return Promise.resolve({ app_review: this.current }); + } + put(url, body) { + this.requests.push(['PUT', url, body]); + if (body.enabled === true) { + this.current = status({ enabled: true, has_code: true, code_generated_at: '2026-10-24T09:00:00+00:00' }); + return Promise.resolve({ app_review: { ...this.current, code: '482913' } }); + } + if (body.regenerate_code) { + return Promise.resolve({ app_review: { ...this.current, code: '135790' } }); + } + this.current = { ...this.current, enabled: false }; + return Promise.resolve({ app_review: this.current }); + } +} + +class ModalsManagerStubService extends Service { + confirmed = []; + confirm(options) { + this.confirmed.push(options.title); + return options.confirm({ startLoading() {}, stopLoading() {}, done() {} }); + } +} + +class AbilitiesStubService extends Service { + can() { + return true; + } + cannot() { + return false; + } +} + +class NotificationsStubService extends Service { + messages = []; + success(message) { + this.messages.push(['success', message]); + } + serverError(error) { + this.messages.push(['error', error?.message]); + } +} + +module('Integration | Component | storefront/app-review-access', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:modals-manager', ModalsManagerStubService); + this.owner.register('service:abilities', AbilitiesStubService); + this.owner.register('service:notifications', NotificationsStubService); + }); + + test('it turns access on and shows the code once, regenerates it and turns it off', async function (assert) { + const fetch = this.owner.lookup('service:fetch'); + this.set('store', new StoreRecord()); + + await render(hbs``); + + assert.deepEqual(fetch.requests[0], ['GET', 'stores/store_1/app-review']); + assert.dom('[data-test-app-review-status]').hasText('Off'); + assert.dom('[data-test-app-review-phone]').includesText('+1 202 555 0143'); + assert.dom('[data-test-app-review-code]').doesNotExist(); + assert.dom('[data-test-app-review-access]').includesText('No code yet'); + + await click('[data-test-app-review-turn-on]'); + + assert.deepEqual(fetch.requests[1], ['PUT', 'stores/store_1/app-review', { enabled: true }]); + assert.dom('[data-test-app-review-status]').hasText('On'); + assert.dom('[data-test-app-review-code]').includesText('482913'); + assert.dom('[data-test-app-review-access]').includesText('only shown once'); + + await click('[data-test-app-review-regenerate]'); + assert.deepEqual(fetch.requests[2], ['PUT', 'stores/store_1/app-review', { regenerate_code: true }]); + assert.dom('[data-test-app-review-code]').includesText('135790'); + + await click('[data-test-app-review-turn-off]'); + assert.deepEqual(fetch.requests[3], ['PUT', 'stores/store_1/app-review', { enabled: false }]); + assert.dom('[data-test-app-review-status]').hasText('Off'); + assert.dom('[data-test-app-review-code]').doesNotExist('the code is forgotten once access is off'); + assert.dom('[data-test-app-review-turn-on]').exists(); + assert.strictEqual(this.owner.lookup('service:modals-manager').confirmed.length, 2, 'regenerating and turning off are confirmed first'); + }); + + test('a code made earlier is never shown again, and a network uses its own endpoint', async function (assert) { + const fetch = this.owner.lookup('service:fetch'); + fetch.current = status({ enabled: true, has_code: true, code_generated_at: '2026-10-20T09:00:00+00:00' }); + this.set('network', new NetworkRecord()); + + await render(hbs``); + + assert.deepEqual(fetch.requests[0], ['GET', 'networks/network_1/app-review']); + assert.dom('[data-test-app-review-status]').hasText('On'); + assert.dom('[data-test-app-review-code]').doesNotExist(); + assert.dom('[data-test-app-review-access]').includesText('Code created'); + assert.dom('[data-test-app-review-regenerate]').exists(); + }); +}); diff --git a/tests/integration/components/storefront/order/panel-app-review-test.js b/tests/integration/components/storefront/order/panel-app-review-test.js new file mode 100644 index 00000000..2c0b5e92 --- /dev/null +++ b/tests/integration/components/storefront/order/panel-app-review-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | storefront/order/panel-app-review', function (hooks) { + setupRenderingTest(hooks); + + test('it marks an order an app store reviewer placed, and nothing else', async function (assert) { + this.set('order', { meta: { app_review: true } }); + await render(hbs``); + + assert.dom('[data-test-order-app-review]').exists(); + assert.dom('[data-test-order-app-review]').includesText('App Review test order'); + + this.set('order', { meta: { total: 100 } }); + assert.dom('[data-test-order-app-review]').doesNotExist(); + + this.set('order', null); + assert.dom('[data-test-order-app-review]').doesNotExist(); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index f3e4babb..fe0b8d5c 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -748,6 +748,9 @@ storefront: completed: Completed canceled: Canceled panel: + app-review: App Review test order + app-review-body: An app store reviewer placed this order while testing your app. Don't prepare or deliver it; cancel it when you're ready. + app-review-order: App Review order customer-note: Note from {name} copy-tracking: Copy tracking number copied: "Copied {value}" @@ -2035,6 +2038,7 @@ storefront: copy-store-key: Copy store key store-key-copied: Store key copied. sections: + app-review: App Review select-gateway-type: Select gateway type hours: Hours locations: Locations @@ -2109,6 +2113,28 @@ storefront: split-tips: A multi-store order's store tip is divided by each store's share; off, the network keeps it. reviews-label: Customer reviews reviews: Customers can review products and stores after a completed order. + app-review: + lede: Apple and Google test your app before it goes live, and their reviewers can't receive a text message. Turn this on to let them sign in with a test phone number and a code you give the app store. + 'on': 'On' + 'off': 'Off' + phone-label: Reviewer phone number + phone-help: A test number that is never texted. It is the same for every store; the code decides which store it signs in to. + code-label: Sign-in code + code-new: Copy this code now. It is only shown once. + code-hidden: Code created {date}. It is only shown once, so create a new one if you've lost it. + code-none: No code yet. Turning this on creates one. + where: In App Store Connect, enter both under App Review Information, Sign-in required. In Google Play Console, enter them under App content, App access. + orders: Orders the reviewer places are marked App Review test order and are never sent to a driver. Don't fulfil them. + turn-on: Turn on + turn-off: Turn off + turn-off-title: Turn off App Review access? + turn-off-body: Reviewers can no longer sign in to {name}. Turn it off once your app is approved, and turn it back on before you submit an update. + regenerate: Create new code + regenerate-title: Create a new code? + regenerate-body: The current code stops working at once. Enter the new one in App Store Connect and Google Play Console. + turned-on: App Review access is on. + turned-off: App Review access is off. + regenerated: New code created. rotate-key: button: Rotate key help: Rotating issues a new key. Apps and integrations using the current key are rejected immediately until they are updated with the new one. From d46890bffb1321f8fd66c8f51340cb27f6865481 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 20:34:47 +0800 Subject: [PATCH 2/9] refactor(app-review): build on core-api's App Review access The mechanism moves to core-api (fleetbase/core-api#323) so Navigator and future apps share it: stores and networks are registered with core as storefront-store and storefront-network, codes live in core's review_accesses table, and the Console calls core's int/v1/review-access endpoint. Storefront keeps the shared review customer, order marking and its env keys. The Storefront-only table, model, trait and routes are removed. Requires core-api ^1.6.74. --- RELEASE.md | 6 +- .../storefront/app-review-access.js | 15 +- composer.json | 2 +- docs/app-review.md | 22 +-- ...00000_create_app_review_accesses_table.php | 47 ----- .../Http/Controllers/NetworkController.php | 10 -- .../src/Http/Controllers/StoreController.php | 11 -- .../Controllers/v1/CustomerController.php | 15 +- server/src/Models/AppReviewAccess.php | 70 -------- .../Providers/StorefrontServiceProvider.php | 13 ++ server/src/Support/AppReview.php | 165 +++++------------- server/src/Traits/ManagesAppReviewAccess.php | 57 ------ server/src/routes.php | 4 - .../Http/Controllers/AppReviewAccessTest.php | 94 +++------- .../Unit/Routes/StorefrontRoutesTest.php | 4 - .../storefront/app-review-access-test.js | 18 +- 16 files changed, 119 insertions(+), 434 deletions(-) delete mode 100644 server/migrations/2026_10_24_000000_create_app_review_accesses_table.php delete mode 100644 server/src/Models/AppReviewAccess.php delete mode 100644 server/src/Traits/ManagesAppReviewAccess.php diff --git a/RELEASE.md b/RELEASE.md index 414f3f39..70611855 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,14 +3,14 @@ --- ## Highlights -- **App Review access.** Apple and Google reviewers can't receive the texted sign-in code, and Apple rejects apps its reviewers can't sign in to. A store or network can now turn on **Settings → App Review**: reviewers sign in with the test number +1 202 555 0143 (a range kept for fiction, never texted) and a six-digit code the owner creates in the Console and enters in App Store Connect and Google Play Console. The code is stored hashed and shown once; it works only in the store that created it. Reviewers can sign up, sign in, add the number at checkout and delete their account without a text message, on Fleetbase Cloud as well as self-hosted. See [docs/app-review.md](docs/app-review.md). +- **App Review access.** Apple and Google reviewers can't receive the texted sign-in code, and Apple rejects apps its reviewers can't sign in to. A store or network can now turn on **Settings → App Review**: reviewers sign in with the test number +1 202 555 0143 (a range kept for fiction, never texted) and a six-digit code the owner creates in the Console and enters in App Store Connect and Google Play Console. The code is stored hashed and shown once; it works only in the store that created it. The mechanism is core-api's App Review access (core-api 1.6.74), which Navigator and future apps share. Reviewers can sign up, sign in, add the number at checkout and delete their account without a text message, on Fleetbase Cloud as well as self-hosted. See [docs/app-review.md](docs/app-review.md). - **Reviewers' orders are marked.** Orders placed by the review account carry `meta.app_review = true`, are labelled **App Review order** in the order panel and staff alerts, and are never auto-dispatched to a driver. - **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code. -- **New internal endpoints** `GET|PUT /storefront/int/v1/stores/{id}/app-review` and `.../networks/{id}/app-review`, and a new permission, `storefront manage-app-review store` / `network`. +- **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. --- ## Upgrading -- Run migrations: adds the `app_review_accesses` table on the Storefront connection. +- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and the `review_accesses` table. Run migrations. - Grant `manage-app-review` on stores or networks to staff who aren't administrators and should manage App Review access (the Storefront Administrator role's full-access policy already covers it). --- diff --git a/addon/components/storefront/app-review-access.js b/addon/components/storefront/app-review-access.js index 16cbc111..38ad0771 100644 --- a/addon/components/storefront/app-review-access.js +++ b/addon/components/storefront/app-review-access.js @@ -6,8 +6,9 @@ import { task } from 'ember-concurrency'; /** * App Review access for a store or network: lets Apple and Google reviewers sign in to its app - * with a fixed test number (never texted) and a code the owner gives the app store. The code - * is stored hashed on the server, so it is shown only in the response that created it. + * with a fixed test number (never texted) and a code the owner gives the app store. Core + * (`int/v1/review-access`) keeps the code hashed, so it is shown only in the response that + * created it. * * Args: @owner (a store or network record). */ @@ -30,7 +31,7 @@ export default class StorefrontAppReviewAccessComponent extends Component { } get endpoint() { - return `${this.type}s/${this.args.owner?.id}/app-review`; + return `review-access/storefront-${this.type}/${this.args.owner?.id}`; } get permission() { @@ -57,8 +58,8 @@ export default class StorefrontAppReviewAccessComponent extends Component { if (!this.args.owner?.id) return; try { - const { app_review } = yield this.fetch.get(this.endpoint, {}, { namespace: 'storefront/int/v1' }); - this.status = app_review; + const { review_access } = yield this.fetch.get(this.endpoint, {}, { namespace: 'int/v1' }); + this.status = review_access; } catch (error) { this.notifications.serverError(error); } @@ -66,8 +67,8 @@ export default class StorefrontAppReviewAccessComponent extends Component { @task *save(payload, message) { try { - const { app_review } = yield this.fetch.put(this.endpoint, payload, { namespace: 'storefront/int/v1' }); - const { code, ...status } = app_review; + const { review_access } = yield this.fetch.put(this.endpoint, payload, { namespace: 'int/v1' }); + const { code, ...status } = review_access; this.status = status; if (code) this.code = code; if (!status.enabled) this.code = null; diff --git a/composer.json b/composer.json index ee591687..dafc8ae1 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "^1.6.71", + "fleetbase/core-api": "^1.6.74", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/docs/app-review.md b/docs/app-review.md index 52936026..83064da4 100644 --- a/docs/app-review.md +++ b/docs/app-review.md @@ -69,23 +69,21 @@ addresses, in every store on the instance, and they are never sent a code either ## For developers -The Console uses two internal endpoints, on stores and networks alike. Both take the -store's or network's id or public id, and are scoped to the signed-in company. +App Review access is core-api's (`Fleetbase\Support\AppReview`, table `review_accesses`, core-api 1.6.74 or later). Storefront registers stores as `storefront-store` and networks as `storefront-network`, with the review number above, and the Console uses core's endpoints. They take the store's or network's id or public id, and are scoped to the signed-in company: ``` -GET /storefront/int/v1/stores/{id}/app-review -PUT /storefront/int/v1/stores/{id}/app-review -GET /storefront/int/v1/networks/{id}/app-review -PUT /storefront/int/v1/networks/{id}/app-review +GET /int/v1/review-access/storefront-store/{id} +PUT /int/v1/review-access/storefront-store/{id} +GET /int/v1/review-access/storefront-network/{id} +PUT /int/v1/review-access/storefront-network/{id} ``` -`PUT` takes `enabled` (true or false) and `regenerate_code` (true to replace the -code), both optional. Turning access on when there is no code creates one. Both -return: +`PUT` takes `enabled` (true or false) and `regenerate_code` (true to replace the code), both optional. Turning access on when there is no code creates one. Both return: ```json { - "app_review": { + "review_access": { + "type": "storefront-store", "enabled": true, "phone": "+12025550143", "phone_display": "+1 202 555 0143", @@ -97,6 +95,4 @@ return: } ``` -`code` is present only in the response that created it. The server stores only a -hash of it, so it can't be read back later; create a new one instead. `GET` needs -the `view` permission on the store or network, `PUT` needs `manage-app-review`. +`code` is present only in the response that created it. The server stores only a hash of it, so it can't be read back later; create a new one instead. `GET` needs `storefront view store` (or `network`), `PUT` needs `storefront manage-app-review store` (or `network`). From PHP, call `Fleetbase\Support\AppReview::update($store, true)` and `AppReview::status($store)`. diff --git a/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php b/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php deleted file mode 100644 index 7ed2c5d9..00000000 --- a/server/migrations/2026_10_24_000000_create_app_review_accesses_table.php +++ /dev/null @@ -1,47 +0,0 @@ -hasTable('app_review_accesses')) { - return; - } - - $schema->create('app_review_accesses', function (Blueprint $table) use ($databaseName) { - $table->bigIncrements('id'); - $table->uuid('uuid')->unique(); - $table->foreignUuid('company_uuid')->nullable()->index()->references('uuid')->on(new Expression($databaseName . '.companies'))->onUpdate('CASCADE')->onDelete('CASCADE'); - $table->uuid('owner_uuid')->unique(); - $table->string('owner_type')->nullable(); - $table->boolean('enabled')->default(false); - $table->string('code')->nullable(); - $table->timestamp('code_generated_at')->nullable(); - $table->timestamp('enabled_at')->nullable(); - $table->foreignUuid('updated_by_uuid')->nullable()->references('uuid')->on(new Expression($databaseName . '.users'))->onUpdate('CASCADE')->onDelete('SET NULL'); - $table->timestamps(); - $table->softDeletes(); - }); - } - - public function down(): void - { - Schema::connection(config('storefront.connection.db'))->dropIfExists('app_review_accesses'); - } -}; diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index ab0c0ec6..2012f796 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -12,7 +12,6 @@ use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\NetworkStore; use Fleetbase\Storefront\Models\Store; -use Fleetbase\Storefront\Traits\ManagesAppReviewAccess; use Fleetbase\Support\ApiModelCache; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -21,8 +20,6 @@ class NetworkController extends StorefrontController { - use ManagesAppReviewAccess; - /** * The resource to query. * @@ -72,13 +69,6 @@ public function rotateKey(string $id, Request $request) return response()->json(['status' => 'ok', 'key' => $network->rotateKey()]); } - protected function appReviewOwner(string $id, Request $request): Store|Network|null - { - return Network::where('company_uuid', $request->session()->get('company')) - ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) - ->first(); - } - /** * Add stores to a network. * diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 5967dbd1..56f508c6 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -2,16 +2,12 @@ namespace Fleetbase\Storefront\Http\Controllers; -use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Store; -use Fleetbase\Storefront\Traits\ManagesAppReviewAccess; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class StoreController extends StorefrontController { - use ManagesAppReviewAccess; - /** * The resource to query. * @@ -58,11 +54,4 @@ public function rotateKey(string $id, Request $request) return response()->json(['status' => 'ok', 'key' => $store->rotateKey()]); } - - protected function appReviewOwner(string $id, Request $request): Store|Network|null - { - return Store::where('company_uuid', $request->session()->get('company')) - ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) - ->first(); - } } diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 155bcfde..81071172 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -1050,20 +1050,7 @@ protected function createCustomerRequestFrom(Request $request): CreateCustomerRe */ protected static function isReviewAccountBypass(?string $identity, $code): bool { - if (blank($code) || !AppReview::isConfiguredReviewAccount($identity)) { - return false; - } - - // hash_equals so a wrong code cannot be recovered by timing the response. - if (!hash_equals((string) config('storefront.storefront_app.bypass_verification_code'), (string) $code)) { - return false; - } - - Log::warning('[Storefront] Verification bypass accepted for a review account.', [ - 'identity' => $identity, - ]); - - return true; + return AppReview::configuredAccountMatches($identity, $code); } /** diff --git a/server/src/Models/AppReviewAccess.php b/server/src/Models/AppReviewAccess.php deleted file mode 100644 index 213312a7..00000000 --- a/server/src/Models/AppReviewAccess.php +++ /dev/null @@ -1,70 +0,0 @@ - 'boolean', - 'code_generated_at' => 'datetime', - 'enabled_at' => 'datetime', - ]; - - public function owner() - { - return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid'); - } - - /** - * Turned on and holding a code: the only state in which a reviewer can sign in. - */ - public function isUsable(): bool - { - return $this->enabled && filled($this->code); - } - - public function hasCode(): bool - { - return filled($this->code); - } - - /** - * Replace the code with the given one, stored hashed. - */ - public function setPlainCode(string $code): void - { - $this->code = Hash::make($code); - $this->code_generated_at = now(); - } - - public function checkCode(string $code): bool - { - return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); - } -} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 4fbf9c4b..83fa1a99 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\AppReview; use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Support\SocketCluster\SocketChannelRegistry; @@ -128,6 +129,18 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); $this->registerStorefrontSocketChannels(); + $this->registerAppReviewTypes(); + } + + /** + * Registers stores and networks with core-api's App Review access, so their owners can let + * app store reviewers sign in to the store's app without a texted code. + */ + public function registerAppReviewTypes(): void + { + if (class_exists(\Fleetbase\Support\AppReview::class)) { + AppReview::register(); + } } /** diff --git a/server/src/Support/AppReview.php b/server/src/Support/AppReview.php index a6cda503..5079275f 100644 --- a/server/src/Support/AppReview.php +++ b/server/src/Support/AppReview.php @@ -5,18 +5,18 @@ use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Models\User; -use Fleetbase\Storefront\Models\AppReviewAccess; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Store; -use Illuminate\Support\Facades\Log; +use Fleetbase\Support\AppReview as CoreAppReview; /** - * App Review access: how Apple and Google reviewers sign in to a store's app. + * App Review access in Storefront: how Apple and Google reviewers sign in to a store's app. * - * Storefront signs customers in with a texted code, which a reviewer cannot receive. A store - * (or network) that turns App Review access on lets the fixed review number below sign in - * with a code its owner generated in the Console and gave to the app store. Nothing is ever - * sent to that number, and the code works only in the storefront that issued it. + * The mechanism is core's (Fleetbase\Support\AppReview): Storefront registers stores and + * networks as review subjects with its review number, the owner turns access on in the + * Console, and a store's code signs the number in to that store only. Core never texts the + * number. This class adds what is Storefront's own: the shared review customer, marking + * reviewers' orders, and the self-hosted env fallback keys. * * Self-hosted installs can still configure review accounts instance-wide with * STOREFRONT_BYPASS_VERIFICATION_CODE and STOREFRONT_REVIEW_ACCOUNTS (config @@ -25,19 +25,16 @@ class AppReview { /** - * The review identity: a number from the North American 555-0100 to 555-0199 range that - * is reserved for fiction, so it can never reach a real person. One number serves every - * store because customers belong to a store: each store's code signs it in to that store - * only. libphonenumber accepts it (US, fixed line or mobile), as does the app's own check. + * Storefront's review number: from the North American 555-0100 to 555-0199 range that is + * reserved for fiction, so it can never reach a real person. One number serves every store + * because customers belong to a store: each store's code signs it in to that store only. + * Navigator registers a number of its own, since drivers and customers are different users. + * libphonenumber accepts it (US, fixed line or mobile), as does the app's own check. */ public const PHONE = '+12025550143'; - public const PHONE_DISPLAY = '+1 202 555 0143'; - - /** - * Digits in a generated code; the app's code screen takes six. - */ - public const CODE_LENGTH = 6; + public const STORE_TYPE = 'storefront-store'; + public const NETWORK_TYPE = 'storefront-network'; /** * Name given to the review account when its first sign-in creates it. @@ -51,38 +48,45 @@ class AppReview public const OFF_MESSAGE = 'This number is reserved for app store review sign-in, which is turned off for this store.'; + protected const CODE_KEY = 'storefront.storefront_app.bypass_verification_code'; + protected const ACCOUNTS_KEY = 'storefront.storefront_app.review_accounts'; + /** - * Whether an identity is the fixed review number, however it is formatted. + * Register stores and networks with core as App Review subjects. */ - public static function isReviewPhone(?string $identity): bool + public static function register(): void { - if (blank($identity)) { - return false; - } - - return preg_replace('/\D+/', '', $identity) === preg_replace('/\D+/', '', static::PHONE); + CoreAppReview::register(static::STORE_TYPE, [ + 'model' => Store::class, + 'phone' => static::PHONE, + 'name' => 'Storefront', + 'permission' => 'storefront manage-app-review store', + 'view_permission' => 'storefront view store', + ]); + CoreAppReview::register(static::NETWORK_TYPE, [ + 'model' => Network::class, + 'phone' => static::PHONE, + 'name' => 'Storefront', + 'permission' => 'storefront manage-app-review network', + 'view_permission' => 'storefront view network', + ]); } /** - * The App Review access record of a store or network, if it has one. + * Whether an identity is Storefront's review number, however it is formatted. */ - public static function accessFor(Store|Network|null $owner): ?AppReviewAccess + public static function isReviewPhone(?string $identity): bool { - if (!$owner || blank($owner->uuid)) { - return null; + if (blank($identity)) { + return false; } - try { - return AppReviewAccess::where('owner_uuid', $owner->uuid)->first(); - } catch (\Throwable $e) { - // Before the migration has run there is no access, not an error. - return null; - } + return preg_replace('/\D+/', '', $identity) === preg_replace('/\D+/', '', static::PHONE); } public static function isEnabledFor(Store|Network|null $owner): bool { - return (bool) static::accessFor($owner)?->isUsable(); + return CoreAppReview::isEnabledFor($owner); } /** @@ -91,20 +95,7 @@ public static function isEnabledFor(Store|Network|null $owner): bool */ public static function acceptsCode(Store|Network|null $owner, ?string $identity, mixed $code): bool { - if (!static::isReviewPhone($identity) || blank($code) || !is_scalar($code)) { - return false; - } - - $access = static::accessFor($owner); - if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { - return false; - } - - Log::info('[Storefront] App Review sign-in accepted.', [ - 'storefront' => $owner->public_id ?? $owner->uuid, - ]); - - return true; + return static::isReviewPhone($identity) && CoreAppReview::acceptsCode($owner, static::PHONE, $code); } /** @@ -113,16 +104,15 @@ public static function acceptsCode(Store|Network|null $owner, ?string $identity, */ public static function isConfiguredReviewAccount(?string $identity): bool { - if (blank($identity) || blank(config('storefront.storefront_app.bypass_verification_code'))) { - return false; - } - - $reviewAccounts = array_map( - static fn ($account) => strtolower(trim((string) $account)), - (array) config('storefront.storefront_app.review_accounts', []) - ); + return CoreAppReview::isConfiguredAccount(static::CODE_KEY, static::ACCOUNTS_KEY, $identity); + } - return in_array(strtolower(trim($identity)), $reviewAccounts, true); + /** + * Whether a code is the instance-wide bypass code for a listed review account. + */ + public static function configuredAccountMatches(?string $identity, mixed $code): bool + { + return CoreAppReview::configuredAccountMatches(static::CODE_KEY, static::ACCOUNTS_KEY, $identity, $code, 'storefront'); } /** @@ -196,63 +186,4 @@ public static function user(Store|Network $owner): User return $user; } - - /** - * A new random code. - */ - public static function generateCode(): string - { - return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); - } - - /** - * Change a storefront's access. Turning it on without a code generates one, so it is - * never on with nothing to sign in with; `regenerate` replaces the code. Turning it off - * keeps the code, so turning it back on restores the same one. - * - * @return array{0: AppReviewAccess, 1: ?string} the access and the new plain code, if one was generated - */ - public static function update(Store|Network $owner, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array - { - $access = static::accessFor($owner) ?? new AppReviewAccess([ - 'company_uuid' => $owner->company_uuid, - 'owner_uuid' => $owner->uuid, - 'owner_type' => get_class($owner), - 'enabled' => false, - ]); - - if ($enabled !== null) { - if ($enabled && !$access->enabled) { - $access->enabled_at = now(); - } - $access->enabled = $enabled; - } - - $code = null; - if ($regenerate || ($access->enabled && !$access->hasCode())) { - $code = static::generateCode(); - $access->setPlainCode($code); - } - - $access->updated_by_uuid = $updatedByUuid; - $access->save(); - - return [$access, $code]; - } - - /** - * What the Console and the internal API show about a storefront's access. Never the code - * or its hash; `code` is added by the caller only in the response that generated it. - */ - public static function status(?AppReviewAccess $access): array - { - return [ - 'enabled' => (bool) $access?->enabled, - 'phone' => static::PHONE, - 'phone_display' => static::PHONE_DISPLAY, - 'has_code' => (bool) $access?->hasCode(), - 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), - 'enabled_at' => $access?->enabled_at?->toIso8601String(), - ]; - } } diff --git a/server/src/Traits/ManagesAppReviewAccess.php b/server/src/Traits/ManagesAppReviewAccess.php deleted file mode 100644 index 6f36d0a2..00000000 --- a/server/src/Traits/ManagesAppReviewAccess.php +++ /dev/null @@ -1,57 +0,0 @@ -appReviewOwner($id, $request); - if (!$owner) { - return response()->error('Storefront not found.', 404); - } - - return response()->json(['app_review' => AppReview::status(AppReview::accessFor($owner))]); - } - - public function manageAppReview(string $id, Request $request) - { - $owner = $this->appReviewOwner($id, $request); - if (!$owner) { - return response()->error('Storefront not found.', 404); - } - - $request->validate([ - 'enabled' => 'sometimes|boolean', - 'regenerate_code' => 'sometimes|boolean', - ]); - - $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; - - [$access, $code] = AppReview::update($owner, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); - - $status = AppReview::status($access); - if ($code !== null) { - $status['code'] = $code; - } - - return response()->json(['app_review' => $status]); - } -} diff --git a/server/src/routes.php b/server/src/routes.php index bd527d23..353fde5d 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -243,8 +243,6 @@ function ($router, $controller) { 'networks', function ($router, $controller) { $router->post('{id}/rotate-key', $controller('rotateKey')); - $router->get('{id}/app-review', $controller('appReview')); - $router->put('{id}/app-review', $controller('manageAppReview')); $router->delete('{id}/remove-category', $controller('deleteCategory')); $router->post('{id}/set-store-category', $controller('addStoreToCategory')); $router->post('{id}/remove-store-category', $controller('removeStoreCategory')); @@ -276,8 +274,6 @@ function ($router, $controller) { 'stores', function ($router, $controller) { $router->post('{id}/rotate-key', $controller('rotateKey')); - $router->get('{id}/app-review', $controller('appReview')); - $router->put('{id}/app-review', $controller('manageAppReview')); } ); $router->fleetbaseRoutes('store-hours'); diff --git a/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php index bf5295c2..1daff29b 100644 --- a/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php +++ b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php @@ -2,18 +2,17 @@ use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Models\ReviewAccess; use Fleetbase\Models\User; use Fleetbase\Storefront\Auth\Schemas\Storefront as StorefrontSchema; -use Fleetbase\Storefront\Http\Controllers\NetworkController; -use Fleetbase\Storefront\Http\Controllers\StoreController; use Fleetbase\Storefront\Http\Controllers\v1\CustomerController; use Fleetbase\Storefront\Http\Requests\CreateCustomerRequest; use Fleetbase\Storefront\Http\Requests\VerifyCreateCustomerRequest; use Fleetbase\Storefront\Http\Resources\Customer; -use Fleetbase\Storefront\Models\AppReviewAccess; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Support\AppReview; +use Fleetbase\Support\AppReview as CoreAppReview; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Session\ArraySessionHandler; @@ -29,7 +28,7 @@ function createAppReviewSchema(): object { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['app_review_accesses', 'personal_access_tokens', 'verification_codes', 'contacts', 'users', 'stores', 'networks', 'companies'] as $table) { + foreach (['review_accesses', 'personal_access_tokens', 'verification_codes', 'contacts', 'users', 'stores', 'networks', 'companies'] as $table) { $schema->dropIfExists($table); } foreach (['stores', 'networks'] as $table) { @@ -105,12 +104,14 @@ function createAppReviewSchema(): object $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); - $schema->create('app_review_accesses', function ($table) { + // core-api's review_accesses table. + $schema->create('review_accesses', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); $table->string('company_uuid')->nullable(); - $table->string('owner_uuid')->unique(); - $table->string('owner_type')->nullable(); + $table->string('subject_type'); + $table->string('subject_uuid'); + $table->unique(['subject_type', 'subject_uuid']); $table->boolean('enabled')->default(false); $table->string('code')->nullable(); $table->timestamp('code_generated_at')->nullable(); @@ -136,13 +137,17 @@ function createAppReviewSchema(): object // nothing, then listen only for what the tests need. Model::unsetEventDispatcher(); Model::clearBootedModels(); - foreach ([User::class, Contact::class, AppReviewAccess::class, Store::class, Network::class] as $class) { + foreach ([User::class, Contact::class, ReviewAccess::class, Store::class, Network::class] as $class) { new $class(); } Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); User::creating(fn ($user) => $user->uuid ??= (string) Str::uuid()); Contact::creating(fn ($contact) => $contact->uuid ??= (string) Str::uuid()); - AppReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + app()->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + + // What StorefrontServiceProvider does on boot. + AppReview::register(); // Anything that would deliver a code is recorded, so a test can prove nothing went out. $sms = new class { @@ -191,7 +196,7 @@ function appReviewRequest(string $storefrontKey, array $input = [], string $clas function turnOnAppReview(string $storeUuid): string { - [, $code] = AppReview::update(Store::where('uuid', $storeUuid)->first(), true); + [, $code] = CoreAppReview::update(Store::where('uuid', $storeUuid)->first(), true); return $code; } @@ -259,7 +264,7 @@ function appReviewUserCount(): int // A store that turned it on and then off: its code stops working. $code = turnOnAppReview('store_a_uuid'); - AppReview::update(Store::where('uuid', 'store_a_uuid')->first(), false); + CoreAppReview::update(Store::where('uuid', 'store_a_uuid')->first(), false); $refused = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); @@ -309,7 +314,7 @@ function appReviewUserCount(): int test('a network has its own App Review access', function () { createAppReviewSchema(); - [, $code] = AppReview::update(Network::where('uuid', 'network_uuid')->first(), true); + [, $code] = CoreAppReview::update(Network::where('uuid', 'network_uuid')->first(), true); $customer = (new CustomerController())->verifyCode(appReviewRequest('network_key', ['identity' => AppReview::PHONE, 'code' => $code])); $atStore = (new CustomerController())->verifyCode(appReviewRequest('store_a_key', ['identity' => AppReview::PHONE, 'code' => $code])); @@ -449,64 +454,19 @@ function appReviewUserCount(): int ->and($reviewOrder->isDirty())->toBeFalse(); }); -test('the Console turns access on with a code shown once, regenerates it and turns it off', function () { +test('stores and networks are registered with core, with the Storefront number and permissions', function () { createAppReviewSchema(); - $request = function (string $company, array $input = [], string $method = 'PUT') { - $request = Request::create('/', $method, $input); - $request->setLaravelSession(new SessionStore('app-review-console', new ArraySessionHandler(120))); - $request->session()->put('company', $company); - return $request; - }; - app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); - Request::macro('validate', function (array $rules) { - return app('validator')->make($this->all(), $rules)->validate(); - }); - - $initial = (new StoreController())->appReview('store_a', $request('company_a', [], 'GET'))->getData(true)['app_review']; - expect($initial)->toBe([ - 'enabled' => false, - 'phone' => AppReview::PHONE, - 'phone_display' => AppReview::PHONE_DISPLAY, - 'has_code' => false, - 'code_generated_at' => null, - 'enabled_at' => null, - ]); + $store = CoreAppReview::definition(AppReview::STORE_TYPE); + $network = CoreAppReview::definition(AppReview::NETWORK_TYPE); - $on = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => true]))->getData(true)['app_review']; - expect($on['enabled'])->toBeTrue() - ->and($on['has_code'])->toBeTrue() - ->and($on['code'])->toMatch('/^\d{6}$/'); - - // Only the hash is stored, and reading the status never returns the code again. - $stored = AppReviewAccess::where('owner_uuid', 'store_a_uuid')->first(); - $status = (new StoreController())->appReview('store_a', $request('company_a', [], 'GET'))->getData(true)['app_review']; - expect($stored->getRawOriginal('code'))->not->toBe($on['code']) - ->and(password_verify($on['code'], $stored->getRawOriginal('code')))->toBeTrue() - ->and($stored->toArray())->not->toHaveKey('code') - ->and($status)->not->toHaveKey('code') - ->and($status['enabled'])->toBeTrue(); - - // Saving again without regenerating keeps the code. - $again = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => true]))->getData(true)['app_review']; - expect($again)->not->toHaveKey('code') - ->and(AppReview::acceptsCode(Store::where('uuid', 'store_a_uuid')->first(), AppReview::PHONE, $on['code']))->toBeTrue(); - - $regenerated = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['regenerate_code' => true]))->getData(true)['app_review']; - $store = Store::where('uuid', 'store_a_uuid')->first(); - expect($regenerated['code'])->toMatch('/^\d{6}$/') - ->and(AppReview::acceptsCode($store, AppReview::PHONE, $regenerated['code']))->toBeTrue() - ->and($regenerated['code'] === $on['code'] || !AppReview::acceptsCode($store, AppReview::PHONE, $on['code']))->toBeTrue(); - - $off = (new StoreController())->manageAppReview('store_a_uuid', $request('company_a', ['enabled' => false]))->getData(true)['app_review']; - expect($off['enabled'])->toBeFalse() - ->and($off['has_code'])->toBeTrue() - ->and(AppReview::acceptsCode($store, AppReview::PHONE, $regenerated['code']))->toBeFalse(); - - // Another company's store and network are not found. - expect((new StoreController())->manageAppReview('store_b_uuid', $request('company_a', ['enabled' => true]))->getStatusCode())->toBe(404) - ->and((new NetworkController())->appReview('network_public', $request('company_b', [], 'GET'))->getStatusCode())->toBe(404) - ->and((new NetworkController())->manageAppReview('network_public', $request('company_a', ['enabled' => true]))->getData(true)['app_review']['enabled'])->toBeTrue(); + expect($store)->toMatchArray(['model' => Store::class, 'phone' => AppReview::PHONE, 'permission' => 'storefront manage-app-review store', 'view_permission' => 'storefront view store']) + ->and($network)->toMatchArray(['model' => Network::class, 'phone' => AppReview::PHONE, 'permission' => 'storefront manage-app-review network', 'view_permission' => 'storefront view network']) + ->and(CoreAppReview::typeOf(Store::where('uuid', 'store_a_uuid')->first()))->toBe(AppReview::STORE_TYPE) + // The Console finds a store by public id in its own company only. + ->and(CoreAppReview::resolveSubject(AppReview::STORE_TYPE, 'store_a', 'company_a')?->uuid)->toBe('store_a_uuid') + ->and(CoreAppReview::resolveSubject(AppReview::STORE_TYPE, 'store_b', 'company_a'))->toBeNull() + ->and(CoreAppReview::status(Store::where('uuid', 'store_a_uuid')->first()))->toMatchArray(['type' => AppReview::STORE_TYPE, 'phone_display' => '+1 202 555 0143', 'enabled' => false]); }); test('managing App Review access is its own permission on stores and networks', function () { diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 94a1d558..fa3540fd 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -107,9 +107,5 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'stores', null], ['POST', '{id}/rotate-key', 'stores:rotateKey'], ['POST', '{id}/rotate-key', 'networks:rotateKey'], - ['GET', '{id}/app-review', 'stores:appReview'], - ['PUT', '{id}/app-review', 'stores:manageAppReview'], - ['GET', '{id}/app-review', 'networks:appReview'], - ['PUT', '{id}/app-review', 'networks:manageAppReview'], )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/tests/integration/components/storefront/app-review-access-test.js b/tests/integration/components/storefront/app-review-access-test.js index 43571c9c..3b9078cd 100644 --- a/tests/integration/components/storefront/app-review-access-test.js +++ b/tests/integration/components/storefront/app-review-access-test.js @@ -31,19 +31,19 @@ class FetchStubService extends Service { current = status(); get(url) { this.requests.push(['GET', url]); - return Promise.resolve({ app_review: this.current }); + return Promise.resolve({ review_access: this.current }); } put(url, body) { this.requests.push(['PUT', url, body]); if (body.enabled === true) { this.current = status({ enabled: true, has_code: true, code_generated_at: '2026-10-24T09:00:00+00:00' }); - return Promise.resolve({ app_review: { ...this.current, code: '482913' } }); + return Promise.resolve({ review_access: { ...this.current, code: '482913' } }); } if (body.regenerate_code) { - return Promise.resolve({ app_review: { ...this.current, code: '135790' } }); + return Promise.resolve({ review_access: { ...this.current, code: '135790' } }); } this.current = { ...this.current, enabled: false }; - return Promise.resolve({ app_review: this.current }); + return Promise.resolve({ review_access: this.current }); } } @@ -90,7 +90,7 @@ module('Integration | Component | storefront/app-review-access', function (hooks await render(hbs``); - assert.deepEqual(fetch.requests[0], ['GET', 'stores/store_1/app-review']); + assert.deepEqual(fetch.requests[0], ['GET', 'review-access/storefront-store/store_1']); assert.dom('[data-test-app-review-status]').hasText('Off'); assert.dom('[data-test-app-review-phone]').includesText('+1 202 555 0143'); assert.dom('[data-test-app-review-code]').doesNotExist(); @@ -98,17 +98,17 @@ module('Integration | Component | storefront/app-review-access', function (hooks await click('[data-test-app-review-turn-on]'); - assert.deepEqual(fetch.requests[1], ['PUT', 'stores/store_1/app-review', { enabled: true }]); + assert.deepEqual(fetch.requests[1], ['PUT', 'review-access/storefront-store/store_1', { enabled: true }]); assert.dom('[data-test-app-review-status]').hasText('On'); assert.dom('[data-test-app-review-code]').includesText('482913'); assert.dom('[data-test-app-review-access]').includesText('only shown once'); await click('[data-test-app-review-regenerate]'); - assert.deepEqual(fetch.requests[2], ['PUT', 'stores/store_1/app-review', { regenerate_code: true }]); + assert.deepEqual(fetch.requests[2], ['PUT', 'review-access/storefront-store/store_1', { regenerate_code: true }]); assert.dom('[data-test-app-review-code]').includesText('135790'); await click('[data-test-app-review-turn-off]'); - assert.deepEqual(fetch.requests[3], ['PUT', 'stores/store_1/app-review', { enabled: false }]); + assert.deepEqual(fetch.requests[3], ['PUT', 'review-access/storefront-store/store_1', { enabled: false }]); assert.dom('[data-test-app-review-status]').hasText('Off'); assert.dom('[data-test-app-review-code]').doesNotExist('the code is forgotten once access is off'); assert.dom('[data-test-app-review-turn-on]').exists(); @@ -122,7 +122,7 @@ module('Integration | Component | storefront/app-review-access', function (hooks await render(hbs``); - assert.deepEqual(fetch.requests[0], ['GET', 'networks/network_1/app-review']); + assert.deepEqual(fetch.requests[0], ['GET', 'review-access/storefront-network/network_1']); assert.dom('[data-test-app-review-status]').hasText('On'); assert.dom('[data-test-app-review-code]').doesNotExist(); assert.dom('[data-test-app-review-access]').includesText('Code created'); From aa7fadbee21bef3a6ed609a8554c3933eb45a983 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 01:15:41 +0800 Subject: [PATCH 3/9] feat(oauth): storefront scopes for Continue with Fleetbase storefront:read lists the token's organization's stores and networks with their public keys; storefront:app-key creates (or returns) one named API key per app for its Storefront app build; storefront:review-access covers the storefront-store and storefront-network types on core's review-access endpoint. --- RELEASE.md | 1 + .../OAuth/StorefrontController.php | 102 ++++++++++++++++++ .../Providers/StorefrontServiceProvider.php | 2 + server/src/Support/OAuthScopes.php | 41 +++++++ server/src/routes.php | 18 ++++ 5 files changed, 164 insertions(+) create mode 100644 server/src/Http/Controllers/OAuth/StorefrontController.php create mode 100644 server/src/Support/OAuthScopes.php diff --git a/RELEASE.md b/RELEASE.md index 70611855..636fae5b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,6 +8,7 @@ - **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code. - **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. +- **Continue with Fleetbase.** Apps signed in with core-api's OAuth provider (core-api 1.6.74) can, with an organization administrator's consent, read the organization's stores and networks with their public keys (`storefront:read`, `GET /storefront/oauth/v1/storefronts`), get a dedicated API key for their Storefront app (`storefront:app-key`, `POST /storefront/oauth/v1/app-key`, listed under Developers → API Keys) and turn App Review access on or off (`storefront:review-access`, through core's `/oauth/v1/review-access/storefront-store/{id}`). This is how AppHelm connects a Cloud customer's storefront without pasting keys. --- ## Upgrading - **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and the `review_accesses` table. Run migrations. diff --git a/server/src/Http/Controllers/OAuth/StorefrontController.php b/server/src/Http/Controllers/OAuth/StorefrontController.php new file mode 100644 index 00000000..ccae599a --- /dev/null +++ b/server/src/Http/Controllers/OAuth/StorefrontController.php @@ -0,0 +1,102 @@ +uuid)->orderBy('name')->get()->map(fn (Store $store) => static::payload($store, 'store')); + $networks = Network::where('company_uuid', $company->uuid)->orderBy('name')->get()->map(fn (Network $network) => static::payload($network, 'network')); + + return new JsonResponse(['storefronts' => $stores->concat($networks)->values()], 200, ['Cache-Control' => 'no-store']); + } + + /** + * POST storefront/oauth/v1/app-key (scope: storefront:app-key). + * + * The Fleetbase API key (`flb_live_…`) a Storefront app is built with. Creates one named key + * per app, in the token's organization, or returns the one this app created before. It is + * listed under Developers → API Keys, where the organization can revoke it. An existing key + * the app didn't create is never returned. + */ + public function appKey(Request $request): JsonResponse + { + $token = AuthenticateOAuthToken::token($request); + $company = AuthenticateOAuthToken::company($request); + $name = 'Storefront app (' . $token->client->name . ')'; + + $credential = ApiCredential::where('company_uuid', $company->uuid) + ->where('name', $name) + ->where('test_mode', false) + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->first(); + + $created = false; + if (!$credential) { + $credential = ApiCredential::create([ + 'company_uuid' => $company->uuid, + 'user_uuid' => $token->user_uuid, + 'name' => $name, + 'test_mode' => false, + ]); + $created = true; + } + + return new JsonResponse([ + 'api_key' => [ + 'id' => $credential->public_id ?? $credential->uuid, + 'name' => $credential->name, + 'key' => $credential->fresh()->key, + 'created' => $created, + 'created_at' => $credential->created_at, + ], + ], $created ? 201 : 200, ['Cache-Control' => 'no-store']); + } + + /** + * @return array + */ + protected static function payload(Store|Network $storefront, string $type): array + { + return [ + 'id' => $storefront->public_id, + 'type' => $type, + 'key' => $storefront->key, + 'name' => $storefront->name, + 'description' => $storefront->description, + 'currency' => $storefront->currency, + 'timezone' => $storefront->timezone, + 'email' => $storefront->email, + 'phone' => $storefront->phone, + 'website' => $storefront->website, + 'logo_url' => $storefront->logo_url, + 'backdrop_url' => $storefront->backdrop_url, + 'online' => (bool) $storefront->online, + 'pickup_enabled' => (bool) data_get($storefront->options, 'pickup_enabled', false), + 'review_access_type' => $type === 'store' ? AppReview::STORE_TYPE : AppReview::NETWORK_TYPE, + ]; + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 83fa1a99..a0ae8b6c 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; use Fleetbase\Storefront\Support\AppReview; +use Fleetbase\Storefront\Support\OAuthScopes; use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Support\SocketCluster\SocketChannelRegistry; @@ -130,6 +131,7 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); $this->registerStorefrontSocketChannels(); $this->registerAppReviewTypes(); + OAuthScopes::register(); } /** diff --git a/server/src/Support/OAuthScopes.php b/server/src/Support/OAuthScopes.php new file mode 100644 index 00000000..af27bbdf --- /dev/null +++ b/server/src/Support/OAuthScopes.php @@ -0,0 +1,41 @@ + "See {organization}'s storefronts and networks, with their public keys", + 'organization' => true, + ]); + + CoreOAuthScopes::register(static::REVIEW_ACCESS, [ + 'description' => "Turn App Review sign-in on or off for {organization}'s storefronts", + 'organization' => true, + 'review_access_types' => [AppReview::STORE_TYPE, AppReview::NETWORK_TYPE], + ]); + + CoreOAuthScopes::register(static::APP_KEY, [ + 'description' => 'Create an API key for your Storefront app in {organization}', + 'organization' => true, + ]); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 353fde5d..37cd75e2 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -31,6 +31,24 @@ function ($router) { $router->match(['get', 'post'], 'orders/balance-paid', 'OrderController@balancePaid'); }); + /* + |-------------------------------------------------------------------------- + | "Continue with Fleetbase" Routes + |-------------------------------------------------------------------------- + | + | For apps holding an access token from core-api's OAuth provider. Each acts + | only on the organization the token is bound to. App Review access for stores + | and networks is core's /oauth/v1/review-access/{type}/{id}. + */ + if (class_exists(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class)) { + Route::prefix('oauth/v1')->middleware(['throttle:600,1'])->group(function ($router) { + $router->get('storefronts', [Fleetbase\Storefront\Http\Controllers\OAuth\StorefrontController::class, 'storefronts']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':storefront:read'); + $router->post('app-key', [Fleetbase\Storefront\Http\Controllers\OAuth\StorefrontController::class, 'appKey']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':storefront:app-key'); + }); + } + /* |-------------------------------------------------------------------------- | Consumable Storefront API Routes From a89fa9d5fb92a267d70ea052000364f5da5b9f11 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 10:44:50 +0800 Subject: [PATCH 4/9] docs(release): v0.4.30 notes cover Continue with Fleetbase and core's OAuth provider --- RELEASE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 636fae5b..ce0252cc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,4 @@ -> v0.4.30 ~ "App store reviewers can sign in" +> v0.4.30 ~ "App store reviewers can sign in, and Continue with Fleetbase" --- ## Highlights @@ -6,12 +6,12 @@ - **App Review access.** Apple and Google reviewers can't receive the texted sign-in code, and Apple rejects apps its reviewers can't sign in to. A store or network can now turn on **Settings → App Review**: reviewers sign in with the test number +1 202 555 0143 (a range kept for fiction, never texted) and a six-digit code the owner creates in the Console and enters in App Store Connect and Google Play Console. The code is stored hashed and shown once; it works only in the store that created it. The mechanism is core-api's App Review access (core-api 1.6.74), which Navigator and future apps share. Reviewers can sign up, sign in, add the number at checkout and delete their account without a text message, on Fleetbase Cloud as well as self-hosted. See [docs/app-review.md](docs/app-review.md). - **Reviewers' orders are marked.** Orders placed by the review account carry `meta.app_review = true`, are labelled **App Review order** in the order panel and staff alerts, and are never auto-dispatched to a driver. - **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code. -- **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. +- **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. (#162) +- **Continue with Fleetbase.** Apps signed in with core-api's OAuth provider (core-api 1.6.74) can, with an organization administrator's consent, read the organization's stores and networks with their public keys (`storefront:read`, `GET /storefront/oauth/v1/storefronts`), get a dedicated API key for their Storefront app (`storefront:app-key`, `POST /storefront/oauth/v1/app-key`, listed under Developers → API Keys) and turn App Review access on or off (`storefront:review-access`, through core's `/oauth/v1/review-access/storefront-store/{id}`). This is how AppHelm connects a Cloud customer's storefront without pasting keys. (#163) -- **Continue with Fleetbase.** Apps signed in with core-api's OAuth provider (core-api 1.6.74) can, with an organization administrator's consent, read the organization's stores and networks with their public keys (`storefront:read`, `GET /storefront/oauth/v1/storefronts`), get a dedicated API key for their Storefront app (`storefront:app-key`, `POST /storefront/oauth/v1/app-key`, listed under Developers → API Keys) and turn App Review access on or off (`storefront:review-access`, through core's `/oauth/v1/review-access/storefront-store/{id}`). This is how AppHelm connects a Cloud customer's storefront without pasting keys. --- ## Upgrading -- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and the `review_accesses` table. Run migrations. +- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access (fleetbase/core-api#323) and the OAuth provider (fleetbase/core-api#324). Run migrations: core adds `review_accesses` and the OAuth tables; Storefront adds none. - Grant `manage-app-review` on stores or networks to staff who aren't administrators and should manage App Review access (the Storefront Administrator role's full-access policy already covers it). --- From e12c0076cd91824544d75dd84d2ed03e7160e86b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 11:33:39 +0800 Subject: [PATCH 5/9] feat(i18n): answer customers in their language on the server The customer app already sends Accept-Language on every request, but the server ignored it, so API errors, push notifications, inbox items and SMS codes stayed English for a customer using the app in Mongolian or Ukrainian. SetStorefrontLocale (first in the storefront.api group) picks the closest shipped language from Accept-Language, answers the request in it, restores the previous locale afterwards, and stores it on the signed-in customer's contact meta when it changed. Order notifications read that stored language (CustomerLocale::for) and set it as the notification locale, so queued pushes are written in it too. An order edit summary is English for the console and driver, so non-English customers get the general "order was updated" line. Customer API messages in the v1 controllers, SetStorefrontSession, the validation rules and ReviewEligibility move to storefront::api, checkout, customers and notifications translation files under server/resources/lang, with en, mn and uk. English text is unchanged. Error codes and gateway/passthrough messages stay as they are. The Pest bootstrap gains a translator, __() and app()->getLocale()/setLocale(). --- scripts/pest-bootstrap.php | 27 +- server/resources/lang/en/api.php | 94 +++++++ server/resources/lang/en/checkout.php | 70 +++++ server/resources/lang/en/customers.php | 49 ++++ server/resources/lang/en/notifications.php | 78 ++++++ server/resources/lang/mn/api.php | 94 +++++++ server/resources/lang/mn/checkout.php | 70 +++++ server/resources/lang/mn/customers.php | 49 ++++ server/resources/lang/mn/notifications.php | 78 ++++++ server/resources/lang/uk/api.php | 94 +++++++ server/resources/lang/uk/checkout.php | 70 +++++ server/resources/lang/uk/customers.php | 49 ++++ server/resources/lang/uk/notifications.php | 78 ++++++ .../Http/Controllers/v1/CartController.php | 4 +- .../Controllers/v1/CheckoutController.php | 106 ++++---- .../Controllers/v1/CustomerController.php | 136 +++++----- .../Controllers/v1/FoodTruckController.php | 8 +- .../Http/Controllers/v1/NetworkController.php | 6 +- .../Controllers/v1/NotificationController.php | 24 +- .../Controllers/v1/OrderChatController.php | 22 +- .../Http/Controllers/v1/OrderController.php | 36 +-- .../Http/Controllers/v1/ProductController.php | 8 +- .../Controllers/v1/PromotionController.php | 2 +- .../Http/Controllers/v1/ReviewController.php | 24 +- .../Controllers/v1/ServiceQuoteController.php | 22 +- .../Http/Controllers/v1/StoreController.php | 14 +- .../Http/Middleware/SetStorefrontLocale.php | 38 +++ .../Http/Middleware/SetStorefrontSession.php | 4 +- server/src/Models/Cart.php | 10 +- .../Notifications/StorefrontOrderAccepted.php | 4 +- .../Notifications/StorefrontOrderActivity.php | 2 +- .../StorefrontOrderBalancePaid.php | 7 +- .../Notifications/StorefrontOrderCanceled.php | 20 +- .../StorefrontOrderChatMessage.php | 10 +- .../StorefrontOrderCompleted.php | 4 +- .../StorefrontOrderDriverAssigned.php | 8 +- .../Notifications/StorefrontOrderEnroute.php | 4 +- .../StorefrontOrderItemUnavailable.php | 6 +- .../Notifications/StorefrontOrderNearby.php | 9 +- .../StorefrontOrderNotification.php | 15 +- .../StorefrontOrderPreparing.php | 4 +- .../StorefrontOrderReadyForPickup.php | 4 +- .../Notifications/StorefrontOrderUpdated.php | 41 ++- .../Providers/StorefrontServiceProvider.php | 2 + server/src/Rules/CartExists.php | 2 +- server/src/Rules/CustomerExists.php | 2 +- server/src/Rules/GatewayExists.php | 2 +- server/src/Rules/IsValidLocation.php | 2 +- server/src/Support/CustomerLocale.php | 93 +++++++ server/src/Support/ReviewEligibility.php | 12 +- .../Middleware/SetStorefrontLocaleTest.php | 244 ++++++++++++++++++ 51 files changed, 1586 insertions(+), 275 deletions(-) create mode 100644 server/resources/lang/en/api.php create mode 100644 server/resources/lang/en/checkout.php create mode 100644 server/resources/lang/en/customers.php create mode 100644 server/resources/lang/en/notifications.php create mode 100644 server/resources/lang/mn/api.php create mode 100644 server/resources/lang/mn/checkout.php create mode 100644 server/resources/lang/mn/customers.php create mode 100644 server/resources/lang/mn/notifications.php create mode 100644 server/resources/lang/uk/api.php create mode 100644 server/resources/lang/uk/checkout.php create mode 100644 server/resources/lang/uk/customers.php create mode 100644 server/resources/lang/uk/notifications.php create mode 100644 server/src/Http/Middleware/SetStorefrontLocale.php create mode 100644 server/src/Support/CustomerLocale.php create mode 100644 server/tests/Unit/Http/Middleware/SetStorefrontLocaleTest.php diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index ffaffe7b..93b76392 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -55,7 +55,7 @@ function logger(): mixed if (get_class($app) === Illuminate\Container\Container::class) { if (!class_exists('Fleetbase\TestSupport\ApplicationContainer')) { - eval('namespace Fleetbase\TestSupport; class ApplicationContainer extends \Illuminate\Container\Container { public function environment(...$environments): string|bool { if ($environments === []) { return "testing"; } return in_array("testing", $environments, true); } public function isProduction(): bool { return false; } public function hasDebugModeEnabled(): bool { return true; } }'); + eval('namespace Fleetbase\TestSupport; class ApplicationContainer extends \Illuminate\Container\Container { public function environment(...$environments): string|bool { if ($environments === []) { return "testing"; } return in_array("testing", $environments, true); } public function isProduction(): bool { return false; } public function hasDebugModeEnabled(): bool { return true; } public function getLocale(): string { return $this->make("translator")->getLocale(); } public function setLocale(string $locale): void { $this->make("translator")->setLocale($locale); } }'); } $app = new Fleetbase\TestSupport\ApplicationContainer(); @@ -113,6 +113,17 @@ function logger(): mixed $app->instance('response', new Fleetbase\TestSupport\ResponseFactory()); } + // Storefront's own translations, as StorefrontServiceProvider loads them in the app. + if (!$app->bound('translator') && class_exists('Illuminate\Translation\FileLoader') && class_exists('Illuminate\Filesystem\Filesystem')) { + $app->singleton('translator', function () { + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\FileLoader(new Illuminate\Filesystem\Filesystem(), __DIR__ . '/../server/resources/lang'), 'en'); + $translator->addNamespace('storefront', __DIR__ . '/../server/resources/lang'); + $translator->setFallback('en'); + + return $translator; + }); + } + if ( !$app->bound('validator') && class_exists('Illuminate\Validation\Factory') @@ -186,6 +197,20 @@ function app(?string $abstract = null, array $parameters = []): mixed } } +if (!function_exists('__')) { + function __(?string $key = null, array $replace = [], ?string $locale = null): mixed + { + return $key === null ? $key : app('translator')->get($key, $replace, $locale); + } +} + +if (!function_exists('trans')) { + function trans(?string $key = null, array $replace = [], ?string $locale = null): mixed + { + return $key === null ? app('translator') : app('translator')->get($key, $replace, $locale); + } +} + if (!function_exists('request')) { function request(?string $key = null, mixed $default = null): mixed { diff --git a/server/resources/lang/en/api.php b/server/resources/lang/en/api.php new file mode 100644 index 00000000..2361f34c --- /dev/null +++ b/server/resources/lang/en/api.php @@ -0,0 +1,94 @@ + 'Customer is not authenticated.', + + 'session' => [ + 'key_missing' => 'Oops! No Storefront key found with this request', + 'key_invalid' => 'Oops! The Storefront key provided was not valid', + ], + + 'validation' => [ + 'cart_not_found' => 'Cart session does not exists.', + 'customer_not_found' => 'No customer found.', + 'gateway_not_found' => 'No gateway by code provided exists.', + 'invalid_location' => 'Invalid :attribute.', + ], + + 'orders' => [ + 'no_order_found' => 'No order found.', + 'not_found' => 'Order not found.', + 'not_authorized_pickup' => 'Not authorized to pickup this order for completion.', + 'not_authorized_view' => 'Not authorized to view this order.', + 'not_authorized_receipt' => 'Not authorized to get receipt for this order.', + 'receipt_unavailable' => 'No receipt available for this payment method.', + 'company_registration_required' => 'Company registration number is required.', + 'not_paid_with_qpay' => 'This order was not paid using QPay.', + 'checkout_not_found' => 'No checkout found for this order.', + 'qpay_not_configured' => 'QPay is not configured.', + 'ebarimt_receipt_failed' => 'Unable to create ebarimt receipt', + ], + + 'notifications' => [ + 'not_authorized_view' => 'Not authorized to view customer notifications', + 'not_authorized_update' => 'Not authorized to update customer notifications', + 'not_authorized_delete' => 'Not authorized to delete customer notifications', + 'not_found' => 'Notification not found.', + 'not_authorized_view_preferences' => 'Not authorized to view notification preferences', + 'not_authorized_update_preferences' => 'Not authorized to update notification preferences', + 'preference_must_be_boolean' => 'The :key preference must be true or false.', + ], + + 'reviews' => [ + 'cannot_find_for_store' => 'Cannot find reviews for store', + 'cannot_count_for_store' => 'Cannot count reviews for store', + 'not_found' => 'Review resource not found.', + 'subject_required' => 'A subject is required.', + 'invalid_subject' => 'Invalid subject for review', + 'disabled' => 'Reviews are turned off.', + 'not_authorized_create' => 'Not authorized to create reviews', + 'not_authorized_delete' => 'Not authorized to delete review', + 'sign_in_required' => 'Sign in to write a review.', + 'unsupported_subject' => 'Only stores and products can be reviewed.', + 'no_completed_order' => 'You can review this after an order with it is completed.', + 'already_reviewed' => 'You have already reviewed this for your completed orders.', + ], + + 'chat' => [ + 'address_locked' => 'The address of this order can no longer change.', + 'place_not_found' => 'Place not found.', + 'message_not_found' => 'Message not found.', + 'closed' => 'This chat closed when the order finished.', + 'not_participant' => 'You are not part of this chat.', + 'store_unavailable' => 'This order has no chat with the store.', + 'driver_not_assigned' => 'You can message your driver once one is assigned to this order.', + ], + + 'stores' => [ + 'not_found' => 'Unable to find store!', + 'lookup_id_required' => 'No ID provided for lookup.', + 'store_or_network_not_found' => 'Unable to find store or network for ID provided.', + 'networks_cannot_have_locations' => 'Networks cannot have locations!', + 'location_not_found' => 'Store location not found!', + ], + + 'networks' => [ + 'stores_cannot_have_stores' => 'Stores cannot have stores!', + 'locations_require_storefront' => 'Store locations require a storefront context!', + 'tags_require_storefront' => 'Tags require a storefront context!', + ], + + 'products' => [ + 'not_found' => 'Product not found.', + 'resource_not_found' => 'Product resource not found.', + 'bookable_service_not_found' => 'Bookable service not found.', + ], + + 'food_trucks' => [ + 'not_found' => 'Food Truck resource not found.', + ], + + 'promotions' => [ + 'not_found' => 'Promotion not found.', + ], +]; diff --git a/server/resources/lang/en/checkout.php b/server/resources/lang/en/checkout.php new file mode 100644 index 00000000..e2a89030 --- /dev/null +++ b/server/resources/lang/en/checkout.php @@ -0,0 +1,70 @@ + 'The cart is empty.', + 'cart_expired' => 'Cart expired', + 'cart_item_store_missing' => 'Every marketplace cart item must identify its store.', + 'cart_store_outside_marketplace' => 'The cart contains a store outside this marketplace.', + 'cart_store_offline' => 'A store in this cart is currently offline.', + 'marketplace_single_store_only' => 'This marketplace only supports one store per cart.', + 'cart_product_unavailable' => 'A product in this cart is no longer available from its store.', + 'cart_store_location_invalid' => 'A store location in this cart is no longer valid.', + 'cart_mixed_currencies' => 'Marketplace carts cannot combine different currencies.', + 'cart_items_unavailable' => 'Some items in your cart are no longer available.', + 'invalid_cart_id' => 'Invalid cart ID provided', + 'invalid_cart_product' => 'Invalid product provided to cart!', + 'invalid_cart_item' => 'Invalid cart item provided to cart!', + 'product_unavailable_in_catalog' => 'This product is not available in the selected catalog.', + 'store_location_unavailable_for_product' => 'The selected store location is not available for this product.', + 'booking_slot_taken' => 'The time you picked for :product was just booked. Please pick another time.', + + // Promotions + 'promotion_code_required' => 'A promotion code is required.', + 'promotion_code_rejected' => 'Promotion code ":code" cannot be applied (:reason).', + + // Customer + 'invalid_customer_id' => 'Invalid customer ID provided', + 'customer_session_mismatch' => 'Customer does not match the authenticated session.', + + // Delivery quotes + 'delivery_origin_missing' => 'No delivery origin!', + 'delivery_destination_missing' => 'No delivery destination!', + 'integrated_vendor_not_found' => 'Integrated vendor not found!', + 'service_rates_unavailable' => 'No service rates available!', + 'marketplace_store_locations_unavailable' => 'One or more store locations are unavailable for this marketplace.', + + // Checkout session + 'checkout_initialize_failed' => 'Unable to initialize checkout!', + 'checkout_session_not_found' => 'Checkout session not found.', + 'checkout_not_found' => 'Checkout not found', + 'checkout_token_missing' => 'Missing checkout token.', + 'checkout_status_failed' => 'Failed to retrieve checkout status', + 'status_parameters_missing' => 'Missing required parameters: checkout and token', + 'notes_must_be_text' => 'Notes must be text.', + + // Payment gateways + 'gateway_not_configured' => 'No gateway configured!', + 'gateway_misconfigured' => 'Gateway not configured correctly!', + 'payment_not_paid' => 'Payment has not been paid!', + 'qpay_payment_incomplete' => 'QPay payment has not been completed.', + 'stripe_not_setup' => 'Stripe not setup.', + 'stripe_gateway_not_configured' => 'No stripe gateway configured!', + 'stripe_authentication_failed' => 'Stripe gateway authentication failed. Verify the configured secret key.', + 'stripe_error' => 'Error from Stripe: :message', + 'payment_intent_retrieve_failed' => 'Failed to retrieve PaymentIntent: :message', + 'payment_intent_update_failed' => 'Failed to update PaymentIntent: :message', + 'payment_intent_not_updatable' => 'PaymentIntent cannot be updated at this stage.', + 'payment_intent_other_checkout' => 'PaymentIntent belongs to another checkout.', + 'ephemeral_key_failed' => 'Failed to create ephemeral key: :message', + 'stripe_payment_intent_not_linked' => 'Stripe PaymentIntent is not linked to this checkout.', + 'stripe_payment_verify_failed' => 'Unable to verify Stripe payment.', + 'stripe_payment_incomplete' => 'Stripe payment has not been completed.', + 'stripe_payment_mismatch' => 'Stripe payment does not match this checkout.', + + // Order capture + 'capture_storefront_missing' => 'No storefront in request to capture order!', + 'capture_network_missing' => 'No network in request to capture order!', + 'capture_in_progress' => 'Order capture is already in progress.', + 'order_store_not_found' => 'A store in this order could not be found, so it was not placed. Your payment has not been used.', +]; diff --git a/server/resources/lang/en/customers.php b/server/resources/lang/en/customers.php new file mode 100644 index 00000000..e94c1169 --- /dev/null +++ b/server/resources/lang/en/customers.php @@ -0,0 +1,49 @@ + 'Not authorized to register device for cutomer', + 'not_authorized_unregister_device' => 'Not authorized to unregister device for customer', + 'device_token_required' => 'A device token is required.', + 'device_platform_invalid' => 'Device platform must be either ios or android.', + 'device_requires_user_account' => 'Unable to register device for customer without a user account.', + 'not_found' => 'Not found.', + 'not_authorized_socket_token' => 'Not authorized to create a socket token for customer.', + 'not_authorized_view_orders' => 'Not authorized to view customers orders', + 'not_authorized_view_places' => 'Not authorized to view customers places', + 'invalid_identity_email' => 'Invalid email provided for identity', + 'account_exists_for_email' => 'An account already exists for this email address. Sign in instead.', + 'account_exists_for_phone' => 'An account already exists for this phone number. Sign in instead.', + 'verification_code_message' => 'Your :name verification code is :code', + 'error_sending_verification_code' => 'Error sending verification code.', + 'identity_required' => 'An identity is required to create a customer.', + 'too_many_attempts' => 'Too many attempts. Try again later.', + 'invalid_verification_code_provided' => 'Invalid verification code provided!', + 'invalid_verification_code' => 'Invalid verification code!', + 'unable_to_verify_code' => 'Unable to verify code.', + 'not_authorized_update_customer' => 'Not authorized to update customer.', + 'customer_not_found' => 'Customer resource not found.', + 'not_authorized_view_customers' => 'Not authorized to view customers.', + 'not_authorized_view_customer' => 'Not authorized to view customer.', + 'password_authentication_failed' => 'Authentication failed using password provided.', + 'no_customer_with_phone' => 'No customer with this phone # found.', + 'unable_to_send_verification_code' => 'Unable to send verification code.', + 'apple_parameters_missing' => 'Missing required Apple authentication parameters.', + 'apple_authentication_invalid' => 'Apple ID authentication is not valid.', + 'google_parameters_missing' => 'Missing required Google authentication parameters.', + 'google_authentication_invalid' => 'Google Sign-In authentication is not valid.', + 'stripe_not_setup' => 'Stripe not setup.', + 'storefront_not_found' => 'Storefront not found.', + 'customer_user_not_found' => 'Customer user account not found.', + 'customer_contact_required' => 'Customer account must have a valid email or phone number linked.', + 'account_closure_code_message' => 'Your :name account closure verification code is :code', + 'account_closure_subject' => ':name account closure request', + 'unable_to_send_account_closure_code' => 'Unable to send account closure verification code.', + 'account_closure_failed' => 'An uknown error occured attempting to close customer account.', + 'not_authorized_request_phone_verification' => 'Not authorized to request phone verification.', + 'not_authorized_verify_phone' => 'Not authorized to verify phone number.', + 'no_user_for_customer' => 'No user associated with this customer.', + 'phone_required_for_verification' => 'A phone number is required to request verification.', + 'phone_already_in_use' => 'This phone number is already associated with another account.', + 'unable_to_send_phone_verification_code' => 'Unable to send phone verification code.', + 'verification_code_not_for_phone' => 'Verification code is not associated with a phone number.', +]; diff --git a/server/resources/lang/en/notifications.php b/server/resources/lang/en/notifications.php new file mode 100644 index 00000000..d5cb073a --- /dev/null +++ b/server/resources/lang/en/notifications.php @@ -0,0 +1,78 @@ + [ + 'subject' => 'Your order from :store has been accepted.', + 'body' => 'Your order was accepted.', + ], + 'order_activity' => [ + 'body' => 'Your order from :store has been updated: :activity.', + ], + 'order_balance_paid' => [ + 'subject_order' => 'Payment received for your order from :store', + 'subject_booking' => 'Payment received for your booking from :store', + 'received' => 'We received your payment of :amount.', + 'left_to_pay' => 'There is :amount left to pay.', + 'fully_paid_order' => 'Your order is fully paid.', + 'fully_paid_booking' => 'Your booking is fully paid.', + ], + 'order_canceled' => [ + 'subject' => 'Your order from :store was canceled', + 'body' => 'Your order from :store has been canceled.', + 'body_reason' => 'Your order from :store has been canceled: :reason.', + 'store_credit' => 'Your payment was returned as store credit; use code :code on your next order.', + 'refunded' => 'Your payment has been refunded and should reach you within a few days.', + 'will_refund' => 'If you were charged, you will be refunded.', + 'no_action' => 'No further action is necessary.', + ], + 'order_chat_message' => [ + 'title' => ':name sent a message', + 'default_name' => 'Your driver', + 'photo' => 'Sent a photo', + ], + 'order_completed' => [ + 'subject' => 'Your order from :store has been delivered', + 'body' => 'Your order from :store has been delivered, enjoy!', + ], + 'order_driver_assigned' => [ + 'subject' => 'Your driver is :driver', + 'body' => 'A driver has been assigned to your order from :store, they are currently en-route for pickup', + 'subject_booking' => 'Your provider is :driver', + 'body_booking' => ':driver will be your provider for your booking with :store.', + ], + 'order_enroute' => [ + 'subject' => 'Your order from :store is on the way!', + 'body' => 'Your order from :store has been picked up, we will update you when your order is nearby', + ], + 'order_item_unavailable' => [ + 'default_item' => 'An item', + 'subject' => ':item is unavailable', + 'body' => ':item from your order is unavailable. :store will replace or remove it and adjust your total if it changes.', + 'body_booking' => ':item from your booking is unavailable. :store will replace or remove it and adjust your total if it changes.', + ], + 'order_nearby' => [ + 'subject' => 'Your order is nearby!', + 'body' => 'Your order from :store is reaching in :time', + ], + 'order_preparing' => [ + 'subject' => 'Your order from :store is being prepared!', + 'body' => 'Your order is getting started.', + ], + 'order_ready' => [ + 'subject' => 'Your order from :store is ready for pickup!', + 'body' => 'You can proceed to pickup your order.', + ], + 'order_updated' => [ + 'subject' => 'Your order from :store was updated', + 'subject_booking' => 'Your booking with :store was updated', + 'subject_moved' => 'Your booking with :store was moved', + 'body' => 'Your order from :store was updated.', + 'booking_time' => 'Your booking is now :when.', + 'booking_time_named' => ':service is now :when.', + 'booking_time_range' => ':start to :end', + 'refunded' => 'The difference has been refunded.', + 'store_credit' => 'The difference was added as store credit: use code :code on your next order.', + 'balance_due' => 'There is a balance to pay.', + 'balance_due_link' => 'There is a balance to pay: :link', + ], +]; diff --git a/server/resources/lang/mn/api.php b/server/resources/lang/mn/api.php new file mode 100644 index 00000000..1a81784a --- /dev/null +++ b/server/resources/lang/mn/api.php @@ -0,0 +1,94 @@ + 'Хэрэглэгч нэвтрээгүй байна.', + + 'session' => [ + 'key_missing' => 'Уучлаарай! Энэ хүсэлтэд Storefront түлхүүр олдсонгүй', + 'key_invalid' => 'Уучлаарай! Өгсөн Storefront түлхүүр хүчингүй байна', + ], + + 'validation' => [ + 'cart_not_found' => 'Сагсны сесс олдсонгүй.', + 'customer_not_found' => 'Хэрэглэгч олдсонгүй.', + 'gateway_not_found' => 'Ийм кодтой төлбөрийн хэрэгсэл байхгүй байна.', + 'invalid_location' => ':attribute буруу байна.', + ], + + 'orders' => [ + 'no_order_found' => 'Захиалга олдсонгүй.', + 'not_found' => 'Захиалга олдсонгүй.', + 'not_authorized_pickup' => 'Та энэ захиалгыг очиж авснаар дуусгах эрхгүй.', + 'not_authorized_view' => 'Та энэ захиалгыг харах эрхгүй.', + 'not_authorized_receipt' => 'Та энэ захиалгын баримтыг авах эрхгүй.', + 'receipt_unavailable' => 'Энэ төлбөрийн хэрэгслээр баримт гаргах боломжгүй.', + 'company_registration_required' => 'Байгууллагын регистрийн дугаар шаардлагатай.', + 'not_paid_with_qpay' => 'Энэ захиалгыг QPay-ээр төлөөгүй байна.', + 'checkout_not_found' => 'Энэ захиалгын төлбөрийн мэдээлэл олдсонгүй.', + 'qpay_not_configured' => 'QPay тохируулагдаагүй байна.', + 'ebarimt_receipt_failed' => 'Ebarimt баримт үүсгэж чадсангүй', + ], + + 'notifications' => [ + 'not_authorized_view' => 'Та хэрэглэгчийн мэдэгдлийг харах эрхгүй', + 'not_authorized_update' => 'Та хэрэглэгчийн мэдэгдлийг шинэчлэх эрхгүй', + 'not_authorized_delete' => 'Та хэрэглэгчийн мэдэгдлийг устгах эрхгүй', + 'not_found' => 'Мэдэгдэл олдсонгүй.', + 'not_authorized_view_preferences' => 'Та мэдэгдлийн тохиргоог харах эрхгүй', + 'not_authorized_update_preferences' => 'Та мэдэгдлийн тохиргоог шинэчлэх эрхгүй', + 'preference_must_be_boolean' => ':key тохиргоо true эсвэл false байх ёстой.', + ], + + 'reviews' => [ + 'cannot_find_for_store' => 'Дэлгүүрийн үнэлгээг олж чадсангүй', + 'cannot_count_for_store' => 'Дэлгүүрийн үнэлгээг тоолж чадсангүй', + 'not_found' => 'Үнэлгээ олдсонгүй.', + 'subject_required' => 'Үнэлэх зүйлийг заана уу.', + 'invalid_subject' => 'Үнэлэх зүйл буруу байна', + 'disabled' => 'Үнэлгээ өгөх боломжийг унтраасан байна.', + 'not_authorized_create' => 'Та үнэлгээ өгөх эрхгүй', + 'not_authorized_delete' => 'Та энэ үнэлгээг устгах эрхгүй', + 'sign_in_required' => 'Үнэлгээ өгөхийн тулд нэвтэрнэ үү.', + 'unsupported_subject' => 'Зөвхөн дэлгүүр болон бүтээгдэхүүнд үнэлгээ өгөх боломжтой.', + 'no_completed_order' => 'Үүнийг агуулсан захиалга дууссаны дараа үнэлгээ өгөх боломжтой.', + 'already_reviewed' => 'Та дууссан захиалгууддаа үүнд аль хэдийн үнэлгээ өгсөн байна.', + ], + + 'chat' => [ + 'address_locked' => 'Энэ захиалгын хаягийг өөрчлөх боломжгүй болсон.', + 'place_not_found' => 'Хаяг олдсонгүй.', + 'message_not_found' => 'Зурвас олдсонгүй.', + 'closed' => 'Захиалга дууссан тул энэ чат хаагдсан.', + 'not_participant' => 'Та энэ чатад оролцогч биш байна.', + 'store_unavailable' => 'Энэ захиалгад дэлгүүртэй харилцах чат байхгүй.', + 'driver_not_assigned' => 'Энэ захиалгад жолооч томилогдсоны дараа түүнд зурвас бичих боломжтой.', + ], + + 'stores' => [ + 'not_found' => 'Дэлгүүр олдсонгүй!', + 'lookup_id_required' => 'Хайх ID өгөөгүй байна.', + 'store_or_network_not_found' => 'Өгсөн ID-д тохирох дэлгүүр эсвэл сүлжээ олдсонгүй.', + 'networks_cannot_have_locations' => 'Сүлжээ салбар байршилтай байж болохгүй!', + 'location_not_found' => 'Дэлгүүрийн байршил олдсонгүй!', + ], + + 'networks' => [ + 'stores_cannot_have_stores' => 'Дэлгүүр дотроо дэлгүүртэй байж болохгүй!', + 'locations_require_storefront' => 'Дэлгүүрийн байршлыг харахын тулд дэлгүүр сонгосон байх шаардлагатай!', + 'tags_require_storefront' => 'Шошгыг харахын тулд дэлгүүр сонгосон байх шаардлагатай!', + ], + + 'products' => [ + 'not_found' => 'Бүтээгдэхүүн олдсонгүй.', + 'resource_not_found' => 'Бүтээгдэхүүн олдсонгүй.', + 'bookable_service_not_found' => 'Захиалах боломжтой үйлчилгээ олдсонгүй.', + ], + + 'food_trucks' => [ + 'not_found' => 'Явуулын дэлгүүр олдсонгүй.', + ], + + 'promotions' => [ + 'not_found' => 'Урамшуулал олдсонгүй.', + ], +]; diff --git a/server/resources/lang/mn/checkout.php b/server/resources/lang/mn/checkout.php new file mode 100644 index 00000000..713227c4 --- /dev/null +++ b/server/resources/lang/mn/checkout.php @@ -0,0 +1,70 @@ + 'Сагс хоосон байна.', + 'cart_expired' => 'Сагсны хугацаа дууссан', + 'cart_item_store_missing' => 'Сагсан дахь бүтээгдэхүүн бүр дэлгүүрээ заасан байх ёстой.', + 'cart_store_outside_marketplace' => 'Сагсанд энэ маркетплейст хамаарахгүй дэлгүүр байна.', + 'cart_store_offline' => 'Сагсан дахь нэг дэлгүүр одоогоор офлайн байна.', + 'marketplace_single_store_only' => 'Энэ маркетплейст нэг сагсанд зөвхөн нэг дэлгүүрийн бүтээгдэхүүн байх боломжтой.', + 'cart_product_unavailable' => 'Сагсан дахь нэг бүтээгдэхүүн дэлгүүрт нь боломжгүй болсон байна.', + 'cart_store_location_invalid' => 'Сагсан дахь дэлгүүрийн байршил хүчингүй болсон байна.', + 'cart_mixed_currencies' => 'Өөр валюттай бүтээгдэхүүнийг нэг сагсанд нэгтгэх боломжгүй.', + 'cart_items_unavailable' => 'Таны сагсан дахь зарим бүтээгдэхүүн боломжгүй болсон байна.', + 'invalid_cart_id' => 'Сагсны ID буруу байна', + 'invalid_cart_product' => 'Сагсанд нэмэх бүтээгдэхүүн буруу байна!', + 'invalid_cart_item' => 'Сагсны бүтээгдэхүүн буруу байна!', + 'product_unavailable_in_catalog' => 'Энэ бүтээгдэхүүн сонгосон каталогт боломжгүй байна.', + 'store_location_unavailable_for_product' => 'Сонгосон дэлгүүрийн байршилд энэ бүтээгдэхүүн боломжгүй байна.', + 'booking_slot_taken' => ':product-д сонгосон цаг дөнгөж сая захиалагдсан байна. Өөр цаг сонгоно уу.', + + // Promotions + 'promotion_code_required' => 'Урамшууллын код оруулна уу.', + 'promotion_code_rejected' => '":code" урамшууллын кодыг ашиглах боломжгүй (:reason).', + + // Customer + 'invalid_customer_id' => 'Хэрэглэгчийн ID буруу байна', + 'customer_session_mismatch' => 'Хэрэглэгч нэвтэрсэн бүртгэлтэй таарахгүй байна.', + + // Delivery quotes + 'delivery_origin_missing' => 'Хүргэлтийн эхлэх цэг алга!', + 'delivery_destination_missing' => 'Хүргэлтийн хаяг алга!', + 'integrated_vendor_not_found' => 'Холбогдсон хүргэлтийн үйлчилгээ олдсонгүй!', + 'service_rates_unavailable' => 'Хүргэлтийн үнэ тариф алга!', + 'marketplace_store_locations_unavailable' => 'Энэ маркетплейст нэг буюу хэд хэдэн дэлгүүрийн байршил боломжгүй байна.', + + // Checkout session + 'checkout_initialize_failed' => 'Төлбөрийг эхлүүлж чадсангүй!', + 'checkout_session_not_found' => 'Төлбөрийн мэдээлэл олдсонгүй.', + 'checkout_not_found' => 'Төлбөр олдсонгүй', + 'checkout_token_missing' => 'Төлбөрийн токен алга.', + 'checkout_status_failed' => 'Төлбөрийн төлөвийг авч чадсангүй', + 'status_parameters_missing' => 'Шаардлагатай параметр дутуу байна: checkout, token', + 'notes_must_be_text' => 'Тэмдэглэл текст байх ёстой.', + + // Payment gateways + 'gateway_not_configured' => 'Төлбөрийн хэрэгсэл тохируулаагүй байна!', + 'gateway_misconfigured' => 'Төлбөрийн хэрэгслийн тохиргоо буруу байна!', + 'payment_not_paid' => 'Төлбөр төлөгдөөгүй байна!', + 'qpay_payment_incomplete' => 'QPay төлбөр хараахан төлөгдөөгүй байна.', + 'stripe_not_setup' => 'Stripe тохируулаагүй байна.', + 'stripe_gateway_not_configured' => 'Stripe төлбөрийн хэрэгсэл тохируулаагүй байна!', + 'stripe_authentication_failed' => 'Stripe-д нэвтэрч чадсангүй. Тохируулсан нууц түлхүүрийг шалгана уу.', + 'stripe_error' => 'Stripe-ийн алдаа: :message', + 'payment_intent_retrieve_failed' => 'PaymentIntent-ийг авч чадсангүй: :message', + 'payment_intent_update_failed' => 'PaymentIntent-ийг шинэчилж чадсангүй: :message', + 'payment_intent_not_updatable' => 'Энэ үе шатанд PaymentIntent-ийг шинэчлэх боломжгүй.', + 'payment_intent_other_checkout' => 'PaymentIntent өөр төлбөрт хамаарч байна.', + 'ephemeral_key_failed' => 'Түр түлхүүр үүсгэж чадсангүй: :message', + 'stripe_payment_intent_not_linked' => 'Stripe PaymentIntent энэ төлбөртэй холбогдоогүй байна.', + 'stripe_payment_verify_failed' => 'Stripe төлбөрийг баталгаажуулж чадсангүй.', + 'stripe_payment_incomplete' => 'Stripe төлбөр хараахан төлөгдөөгүй байна.', + 'stripe_payment_mismatch' => 'Stripe төлбөр энэ захиалгын төлбөртэй таарахгүй байна.', + + // Order capture + 'capture_storefront_missing' => 'Захиалга үүсгэх хүсэлтэд дэлгүүр заагаагүй байна!', + 'capture_network_missing' => 'Захиалга үүсгэх хүсэлтэд маркетплейс заагаагүй байна!', + 'capture_in_progress' => 'Захиалгыг аль хэдийн үүсгэж байна.', + 'order_store_not_found' => 'Энэ захиалгын нэг дэлгүүр олдсонгүй тул захиалга үүсээгүй. Таны төлбөр ашиглагдаагүй.', +]; diff --git a/server/resources/lang/mn/customers.php b/server/resources/lang/mn/customers.php new file mode 100644 index 00000000..1fa2b2ca --- /dev/null +++ b/server/resources/lang/mn/customers.php @@ -0,0 +1,49 @@ + 'Энэ хэрэглэгчид төхөөрөмж бүртгэх эрхгүй байна.', + 'not_authorized_unregister_device' => 'Энэ хэрэглэгчийн төхөөрөмжийг бүртгэлээс хасах эрхгүй байна.', + 'device_token_required' => 'Төхөөрөмжийн токен шаардлагатай.', + 'device_platform_invalid' => 'Төхөөрөмжийн платформ ios эсвэл android байх ёстой.', + 'device_requires_user_account' => 'Хэрэглэгчийн бүртгэлгүйгээр төхөөрөмж бүртгэх боломжгүй.', + 'not_found' => 'Олдсонгүй.', + 'not_authorized_socket_token' => 'Энэ хэрэглэгчид сокет токен үүсгэх эрхгүй байна.', + 'not_authorized_view_orders' => 'Энэ хэрэглэгчийн захиалгуудыг харах эрхгүй байна.', + 'not_authorized_view_places' => 'Энэ хэрэглэгчийн хаягуудыг харах эрхгүй байна.', + 'invalid_identity_email' => 'Буруу имэйл хаяг оруулсан байна.', + 'account_exists_for_email' => 'Энэ имэйл хаягаар бүртгэл аль хэдийн үүссэн байна. Нэвтэрнэ үү.', + 'account_exists_for_phone' => 'Энэ утасны дугаараар бүртгэл аль хэдийн үүссэн байна. Нэвтэрнэ үү.', + 'verification_code_message' => 'Таны :name баталгаажуулах код: :code', + 'error_sending_verification_code' => 'Баталгаажуулах код илгээхэд алдаа гарлаа.', + 'identity_required' => 'Хэрэглэгч үүсгэхийн тулд имэйл эсвэл утасны дугаар шаардлагатай.', + 'too_many_attempts' => 'Хэт олон оролдлого хийлээ. Дараа дахин оролдоно уу.', + 'invalid_verification_code_provided' => 'Баталгаажуулах код буруу байна!', + 'invalid_verification_code' => 'Баталгаажуулах код буруу байна!', + 'unable_to_verify_code' => 'Кодыг баталгаажуулж чадсангүй.', + 'not_authorized_update_customer' => 'Хэрэглэгчийн мэдээллийг шинэчлэх эрхгүй байна.', + 'customer_not_found' => 'Хэрэглэгч олдсонгүй.', + 'not_authorized_view_customers' => 'Хэрэглэгчдийг харах эрхгүй байна.', + 'not_authorized_view_customer' => 'Хэрэглэгчийг харах эрхгүй байна.', + 'password_authentication_failed' => 'Нууц үг буруу тул нэвтэрч чадсангүй.', + 'no_customer_with_phone' => 'Энэ утасны дугаартай хэрэглэгч олдсонгүй.', + 'unable_to_send_verification_code' => 'Баталгаажуулах код илгээж чадсангүй.', + 'apple_parameters_missing' => 'Apple-ээр нэвтрэхэд шаардлагатай мэдээлэл дутуу байна.', + 'apple_authentication_invalid' => 'Apple ID-ээр нэвтрэх баталгаажуулалт хүчингүй байна.', + 'google_parameters_missing' => 'Google-ээр нэвтрэхэд шаардлагатай мэдээлэл дутуу байна.', + 'google_authentication_invalid' => 'Google-ээр нэвтрэх баталгаажуулалт хүчингүй байна.', + 'stripe_not_setup' => 'Stripe тохируулагдаагүй байна.', + 'storefront_not_found' => 'Дэлгүүр олдсонгүй.', + 'customer_user_not_found' => 'Хэрэглэгчийн бүртгэл олдсонгүй.', + 'customer_contact_required' => 'Бүртгэлд хүчинтэй имэйл эсвэл утасны дугаар холбогдсон байх шаардлагатай.', + 'account_closure_code_message' => 'Таны :name бүртгэл устгах баталгаажуулах код: :code', + 'account_closure_subject' => ':name бүртгэл устгах хүсэлт', + 'unable_to_send_account_closure_code' => 'Бүртгэл устгах баталгаажуулах код илгээж чадсангүй.', + 'account_closure_failed' => 'Бүртгэл устгах үед тодорхойгүй алдаа гарлаа.', + 'not_authorized_request_phone_verification' => 'Утасны дугаар баталгаажуулах хүсэлт илгээх эрхгүй байна.', + 'not_authorized_verify_phone' => 'Утасны дугаар баталгаажуулах эрхгүй байна.', + 'no_user_for_customer' => 'Энэ хэрэглэгчтэй холбогдсон бүртгэл алга.', + 'phone_required_for_verification' => 'Баталгаажуулалт хүсэхийн тулд утасны дугаар шаардлагатай.', + 'phone_already_in_use' => 'Энэ утасны дугаар өөр бүртгэлд холбогдсон байна.', + 'unable_to_send_phone_verification_code' => 'Утасны баталгаажуулах код илгээж чадсангүй.', + 'verification_code_not_for_phone' => 'Баталгаажуулах код утасны дугаартай холбогдоогүй байна.', +]; diff --git a/server/resources/lang/mn/notifications.php b/server/resources/lang/mn/notifications.php new file mode 100644 index 00000000..deb44f65 --- /dev/null +++ b/server/resources/lang/mn/notifications.php @@ -0,0 +1,78 @@ + [ + 'subject' => ':store таны захиалгыг хүлээн авлаа.', + 'body' => 'Таны захиалга баталгаажлаа.', + ], + 'order_activity' => [ + 'body' => ':store: таны захиалга шинэчлэгдлээ: :activity.', + ], + 'order_balance_paid' => [ + 'subject_order' => ':store таны захиалгын төлбөрийг хүлээн авлаа', + 'subject_booking' => ':store таны цаг захиалгын төлбөрийг хүлээн авлаа', + 'received' => 'Таны :amount төлбөрийг хүлээн авлаа.', + 'left_to_pay' => 'Төлөх үлдэгдэл: :amount.', + 'fully_paid_order' => 'Таны захиалгын төлбөр бүрэн төлөгдсөн.', + 'fully_paid_booking' => 'Таны цаг захиалгын төлбөр бүрэн төлөгдсөн.', + ], + 'order_canceled' => [ + 'subject' => ':store таны захиалгыг цуцаллаа', + 'body' => ':store таны захиалгыг цуцаллаа.', + 'body_reason' => ':store таны захиалгыг цуцаллаа: :reason.', + 'store_credit' => 'Таны төлбөрийг дэлгүүрийн кредит болгон буцаалаа. Дараагийн захиалгадаа :code кодыг ашиглаарай.', + 'refunded' => 'Таны төлбөрийг буцаан олголоо. Хэдэн хоногийн дотор танд хүрнэ.', + 'will_refund' => 'Хэрэв танаас төлбөр авсан бол буцаан олгоно.', + 'no_action' => 'Танаас өөр зүйл хийх шаардлагагүй.', + ], + 'order_chat_message' => [ + 'title' => ':name танд мессеж илгээлээ', + 'default_name' => 'Таны жолооч', + 'photo' => 'Зураг илгээлээ', + ], + 'order_completed' => [ + 'subject' => ':store-ийн таны захиалга хүргэгдлээ', + 'body' => ':store-ийн таны захиалга хүргэгдлээ. Сайхан хэрэглээрэй!', + ], + 'order_driver_assigned' => [ + 'subject' => 'Таны жолооч: :driver', + 'body' => ':store-ийн таны захиалгад жолооч томилогдлоо. Жолооч захиалгыг авахаар явж байна', + 'subject_booking' => 'Таны үйлчилгээ үзүүлэгч: :driver', + 'body_booking' => ':store дахь таны цаг захиалгад :driver үйлчилгээ үзүүлнэ.', + ], + 'order_enroute' => [ + 'subject' => ':store-ийн таны захиалга замдаа гарлаа!', + 'body' => ':store-ийн таны захиалгыг авч явлаа. Ойртоход тань мэдэгдэнэ', + ], + 'order_item_unavailable' => [ + 'default_item' => 'Нэг бараа', + 'subject' => ':item байхгүй байна', + 'body' => 'Таны захиалгын :item байхгүй байна. :store үүнийг солих эсвэл хасаад, дүн өөрчлөгдвөл тохируулна.', + 'body_booking' => 'Таны цаг захиалгын :item боломжгүй байна. :store үүнийг солих эсвэл хасаад, дүн өөрчлөгдвөл тохируулна.', + ], + 'order_nearby' => [ + 'subject' => 'Таны захиалга ойртож байна!', + 'body' => ':store-ийн таны захиалга ирэхэд :time үлдлээ', + ], + 'order_preparing' => [ + 'subject' => ':store таны захиалгыг бэлтгэж байна!', + 'body' => 'Таны захиалгыг бэлтгэж эхэллээ.', + ], + 'order_ready' => [ + 'subject' => ':store-ийн таны захиалга авахад бэлэн боллоо!', + 'body' => 'Та захиалгаа очиж авах боломжтой.', + ], + 'order_updated' => [ + 'subject' => ':store-ийн таны захиалга шинэчлэгдлээ', + 'subject_booking' => ':store дахь таны цаг захиалга шинэчлэгдлээ', + 'subject_moved' => ':store дахь таны цаг захиалгын цаг өөрчлөгдлөө', + 'body' => ':store-ийн таны захиалга шинэчлэгдлээ.', + 'booking_time' => 'Таны цаг захиалга одоо :when.', + 'booking_time_named' => ':service одоо :when.', + 'booking_time_range' => ':start-аас :end', + 'refunded' => 'Зөрүүг буцаан олголоо.', + 'store_credit' => 'Зөрүүг дэлгүүрийн кредит болгон нэмлээ. Дараагийн захиалгадаа :code кодыг ашиглаарай.', + 'balance_due' => 'Төлөх үлдэгдэл байна.', + 'balance_due_link' => 'Төлөх үлдэгдэл байна: :link', + ], +]; diff --git a/server/resources/lang/uk/api.php b/server/resources/lang/uk/api.php new file mode 100644 index 00000000..6dcdab40 --- /dev/null +++ b/server/resources/lang/uk/api.php @@ -0,0 +1,94 @@ + 'Покупець не автентифікований.', + + 'session' => [ + 'key_missing' => 'Ой! У цьому запиті не знайдено ключа Storefront', + 'key_invalid' => 'Ой! Наданий ключ Storefront недійсний', + ], + + 'validation' => [ + 'cart_not_found' => 'Сесію кошика не знайдено.', + 'customer_not_found' => 'Покупця не знайдено.', + 'gateway_not_found' => 'Платіжного шлюзу з таким кодом не існує.', + 'invalid_location' => 'Недійсне значення :attribute.', + ], + + 'orders' => [ + 'no_order_found' => 'Замовлення не знайдено.', + 'not_found' => 'Замовлення не знайдено.', + 'not_authorized_pickup' => 'Ви не можете завершити це замовлення як самовивіз.', + 'not_authorized_view' => 'Ви не маєте доступу до цього замовлення.', + 'not_authorized_receipt' => 'Ви не можете отримати чек для цього замовлення.', + 'receipt_unavailable' => 'Для цього способу оплати чек недоступний.', + 'company_registration_required' => 'Потрібен реєстраційний номер компанії.', + 'not_paid_with_qpay' => 'Це замовлення не було оплачено через QPay.', + 'checkout_not_found' => 'Для цього замовлення не знайдено оформлення.', + 'qpay_not_configured' => 'QPay не налаштовано.', + 'ebarimt_receipt_failed' => 'Не вдалося створити чек ebarimt', + ], + + 'notifications' => [ + 'not_authorized_view' => 'Ви не маєте доступу до сповіщень покупця', + 'not_authorized_update' => 'Ви не можете оновлювати сповіщення покупця', + 'not_authorized_delete' => 'Ви не можете видаляти сповіщення покупця', + 'not_found' => 'Сповіщення не знайдено.', + 'not_authorized_view_preferences' => 'Ви не маєте доступу до налаштувань сповіщень', + 'not_authorized_update_preferences' => 'Ви не можете оновлювати налаштування сповіщень', + 'preference_must_be_boolean' => 'Налаштування :key має бути true або false.', + ], + + 'reviews' => [ + 'cannot_find_for_store' => 'Не вдалося знайти відгуки для магазину', + 'cannot_count_for_store' => 'Не вдалося порахувати відгуки для магазину', + 'not_found' => 'Відгук не знайдено.', + 'subject_required' => 'Вкажіть, що ви оцінюєте.', + 'invalid_subject' => 'Недійсний об’єкт відгуку', + 'disabled' => 'Відгуки вимкнено.', + 'not_authorized_create' => 'Ви не можете залишати відгуки', + 'not_authorized_delete' => 'Ви не можете видалити цей відгук', + 'sign_in_required' => 'Увійдіть, щоб залишити відгук.', + 'unsupported_subject' => 'Залишати відгуки можна лише про магазини та товари.', + 'no_completed_order' => 'Ви зможете залишити відгук після завершення замовлення з цим.', + 'already_reviewed' => 'Ви вже залишили відгук про це для своїх завершених замовлень.', + ], + + 'chat' => [ + 'address_locked' => 'Адресу цього замовлення вже не можна змінити.', + 'place_not_found' => 'Адресу не знайдено.', + 'message_not_found' => 'Повідомлення не знайдено.', + 'closed' => 'Цей чат закрито, бо замовлення завершено.', + 'not_participant' => 'Ви не є учасником цього чату.', + 'store_unavailable' => 'У цього замовлення немає чату з магазином.', + 'driver_not_assigned' => 'Ви зможете написати водієві, щойно його призначать на це замовлення.', + ], + + 'stores' => [ + 'not_found' => 'Не вдалося знайти магазин!', + 'lookup_id_required' => 'Не вказано ID для пошуку.', + 'store_or_network_not_found' => 'Не вдалося знайти магазин або мережу за вказаним ID.', + 'networks_cannot_have_locations' => 'Мережі не можуть мати локацій!', + 'location_not_found' => 'Локацію магазину не знайдено!', + ], + + 'networks' => [ + 'stores_cannot_have_stores' => 'Магазини не можуть містити інші магазини!', + 'locations_require_storefront' => 'Для локацій магазину потрібен контекст магазину!', + 'tags_require_storefront' => 'Для тегів потрібен контекст магазину!', + ], + + 'products' => [ + 'not_found' => 'Товар не знайдено.', + 'resource_not_found' => 'Товар не знайдено.', + 'bookable_service_not_found' => 'Послугу для бронювання не знайдено.', + ], + + 'food_trucks' => [ + 'not_found' => 'Фудтрак не знайдено.', + ], + + 'promotions' => [ + 'not_found' => 'Акцію не знайдено.', + ], +]; diff --git a/server/resources/lang/uk/checkout.php b/server/resources/lang/uk/checkout.php new file mode 100644 index 00000000..3c14e6b7 --- /dev/null +++ b/server/resources/lang/uk/checkout.php @@ -0,0 +1,70 @@ + 'Кошик порожній.', + 'cart_expired' => 'Термін дії кошика минув', + 'cart_item_store_missing' => 'Для кожного товару в кошику маркетплейсу має бути вказано магазин.', + 'cart_store_outside_marketplace' => 'У кошику є магазин, що не належить до цього маркетплейсу.', + 'cart_store_offline' => 'Один із магазинів у кошику зараз зачинений.', + 'marketplace_single_store_only' => 'На цьому маркетплейсі в одному кошику можуть бути товари лише одного магазину.', + 'cart_product_unavailable' => 'Один із товарів у кошику більше недоступний у своєму магазині.', + 'cart_store_location_invalid' => 'Одне з розташувань магазину в кошику більше не дійсне.', + 'cart_mixed_currencies' => 'Товари в різних валютах не можна поєднати в одному кошику.', + 'cart_items_unavailable' => 'Деякі товари у вашому кошику більше недоступні.', + 'invalid_cart_id' => 'Недійсний ID кошика', + 'invalid_cart_product' => 'Недійсний товар для кошика!', + 'invalid_cart_item' => 'Недійсна позиція кошика!', + 'product_unavailable_in_catalog' => 'Цей товар недоступний у вибраному каталозі.', + 'store_location_unavailable_for_product' => 'Вибране розташування магазину недоступне для цього товару.', + 'booking_slot_taken' => 'Час, який ви вибрали для :product, щойно забронювали. Виберіть інший час.', + + // Promotions + 'promotion_code_required' => 'Введіть промокод.', + 'promotion_code_rejected' => 'Промокод «:code» не можна застосувати (:reason).', + + // Customer + 'invalid_customer_id' => 'Недійсний ID клієнта', + 'customer_session_mismatch' => 'Клієнт не відповідає обліковому запису, в який виконано вхід.', + + // Delivery quotes + 'delivery_origin_missing' => 'Не вказано адресу відправлення!', + 'delivery_destination_missing' => 'Не вказано адресу доставки!', + 'integrated_vendor_not_found' => 'Підключену службу доставки не знайдено!', + 'service_rates_unavailable' => 'Немає доступних тарифів доставки!', + 'marketplace_store_locations_unavailable' => 'Одне або кілька розташувань магазинів недоступні на цьому маркетплейсі.', + + // Checkout session + 'checkout_initialize_failed' => 'Не вдалося розпочати оформлення замовлення!', + 'checkout_session_not_found' => 'Сеанс оформлення замовлення не знайдено.', + 'checkout_not_found' => 'Оформлення замовлення не знайдено', + 'checkout_token_missing' => 'Відсутній токен оформлення замовлення.', + 'checkout_status_failed' => 'Не вдалося отримати статус оформлення замовлення', + 'status_parameters_missing' => 'Відсутні обовʼязкові параметри: checkout і token', + 'notes_must_be_text' => 'Примітки мають бути текстом.', + + // Payment gateways + 'gateway_not_configured' => 'Платіжний шлюз не налаштовано!', + 'gateway_misconfigured' => 'Платіжний шлюз налаштовано неправильно!', + 'payment_not_paid' => 'Оплату не здійснено!', + 'qpay_payment_incomplete' => 'Оплату QPay не завершено.', + 'stripe_not_setup' => 'Stripe не налаштовано.', + 'stripe_gateway_not_configured' => 'Платіжний шлюз Stripe не налаштовано!', + 'stripe_authentication_failed' => 'Не вдалося автентифікуватися у Stripe. Перевірте налаштований секретний ключ.', + 'stripe_error' => 'Помилка Stripe: :message', + 'payment_intent_retrieve_failed' => 'Не вдалося отримати PaymentIntent: :message', + 'payment_intent_update_failed' => 'Не вдалося оновити PaymentIntent: :message', + 'payment_intent_not_updatable' => 'На цьому етапі PaymentIntent не можна оновити.', + 'payment_intent_other_checkout' => 'PaymentIntent належить до іншого оформлення замовлення.', + 'ephemeral_key_failed' => 'Не вдалося створити тимчасовий ключ: :message', + 'stripe_payment_intent_not_linked' => 'Stripe PaymentIntent не повʼязаний із цим оформленням замовлення.', + 'stripe_payment_verify_failed' => 'Не вдалося перевірити оплату Stripe.', + 'stripe_payment_incomplete' => 'Оплату Stripe не завершено.', + 'stripe_payment_mismatch' => 'Оплата Stripe не відповідає цьому оформленню замовлення.', + + // Order capture + 'capture_storefront_missing' => 'У запиті на створення замовлення не вказано магазин!', + 'capture_network_missing' => 'У запиті на створення замовлення не вказано маркетплейс!', + 'capture_in_progress' => 'Замовлення вже створюється.', + 'order_store_not_found' => 'Не вдалося знайти один із магазинів у цьому замовленні, тому його не оформлено. Вашу оплату не використано.', +]; diff --git a/server/resources/lang/uk/customers.php b/server/resources/lang/uk/customers.php new file mode 100644 index 00000000..9324aba5 --- /dev/null +++ b/server/resources/lang/uk/customers.php @@ -0,0 +1,49 @@ + 'Немає дозволу реєструвати пристрій для цього покупця.', + 'not_authorized_unregister_device' => 'Немає дозволу скасовувати реєстрацію пристрою цього покупця.', + 'device_token_required' => 'Потрібен токен пристрою.', + 'device_platform_invalid' => 'Платформа пристрою має бути ios або android.', + 'device_requires_user_account' => 'Неможливо зареєструвати пристрій для покупця без облікового запису.', + 'not_found' => 'Не знайдено.', + 'not_authorized_socket_token' => 'Немає дозволу створювати токен сокета для цього покупця.', + 'not_authorized_view_orders' => 'Немає дозволу переглядати замовлення цього покупця.', + 'not_authorized_view_places' => 'Немає дозволу переглядати адреси цього покупця.', + 'invalid_identity_email' => 'Вказано недійсну адресу електронної пошти.', + 'account_exists_for_email' => 'Обліковий запис із цією адресою електронної пошти вже існує. Увійдіть.', + 'account_exists_for_phone' => 'Обліковий запис із цим номером телефону вже існує. Увійдіть.', + 'verification_code_message' => 'Ваш код підтвердження :name: :code', + 'error_sending_verification_code' => 'Помилка надсилання коду підтвердження.', + 'identity_required' => 'Щоб створити покупця, потрібна електронна пошта або номер телефону.', + 'too_many_attempts' => 'Забагато спроб. Спробуйте пізніше.', + 'invalid_verification_code_provided' => 'Невірний код підтвердження!', + 'invalid_verification_code' => 'Невірний код підтвердження!', + 'unable_to_verify_code' => 'Не вдалося перевірити код.', + 'not_authorized_update_customer' => 'Немає дозволу оновлювати дані покупця.', + 'customer_not_found' => 'Покупця не знайдено.', + 'not_authorized_view_customers' => 'Немає дозволу переглядати покупців.', + 'not_authorized_view_customer' => 'Немає дозволу переглядати покупця.', + 'password_authentication_failed' => 'Не вдалося увійти з указаним паролем.', + 'no_customer_with_phone' => 'Покупця з цим номером телефону не знайдено.', + 'unable_to_send_verification_code' => 'Не вдалося надіслати код підтвердження.', + 'apple_parameters_missing' => 'Бракує обовʼязкових параметрів для входу через Apple.', + 'apple_authentication_invalid' => 'Автентифікація Apple ID недійсна.', + 'google_parameters_missing' => 'Бракує обовʼязкових параметрів для входу через Google.', + 'google_authentication_invalid' => 'Автентифікація Google недійсна.', + 'stripe_not_setup' => 'Stripe не налаштовано.', + 'storefront_not_found' => 'Магазин не знайдено.', + 'customer_user_not_found' => 'Обліковий запис покупця не знайдено.', + 'customer_contact_required' => 'До облікового запису має бути привʼязана дійсна електронна пошта або номер телефону.', + 'account_closure_code_message' => 'Ваш код підтвердження видалення облікового запису :name: :code', + 'account_closure_subject' => ':name: запит на видалення облікового запису', + 'unable_to_send_account_closure_code' => 'Не вдалося надіслати код підтвердження видалення облікового запису.', + 'account_closure_failed' => 'Під час видалення облікового запису сталася невідома помилка.', + 'not_authorized_request_phone_verification' => 'Немає дозволу запитувати підтвердження телефону.', + 'not_authorized_verify_phone' => 'Немає дозволу підтверджувати номер телефону.', + 'no_user_for_customer' => 'З цим покупцем не повʼязано жодного облікового запису.', + 'phone_required_for_verification' => 'Щоб запитати підтвердження, потрібен номер телефону.', + 'phone_already_in_use' => 'Цей номер телефону вже привʼязано до іншого облікового запису.', + 'unable_to_send_phone_verification_code' => 'Не вдалося надіслати код підтвердження телефону.', + 'verification_code_not_for_phone' => 'Код підтвердження не повʼязаний із номером телефону.', +]; diff --git a/server/resources/lang/uk/notifications.php b/server/resources/lang/uk/notifications.php new file mode 100644 index 00000000..b8a0b995 --- /dev/null +++ b/server/resources/lang/uk/notifications.php @@ -0,0 +1,78 @@ + [ + 'subject' => ':store прийняв ваше замовлення.', + 'body' => 'Ваше замовлення прийнято.', + ], + 'order_activity' => [ + 'body' => 'Ваше замовлення в :store оновлено: :activity.', + ], + 'order_balance_paid' => [ + 'subject_order' => 'Оплату за ваше замовлення в :store отримано', + 'subject_booking' => 'Оплату за ваше бронювання в :store отримано', + 'received' => 'Ми отримали вашу оплату на суму :amount.', + 'left_to_pay' => 'Залишилося сплатити :amount.', + 'fully_paid_order' => 'Ваше замовлення повністю оплачено.', + 'fully_paid_booking' => 'Ваше бронювання повністю оплачено.', + ], + 'order_canceled' => [ + 'subject' => 'Ваше замовлення в :store скасовано', + 'body' => 'Ваше замовлення в :store скасовано.', + 'body_reason' => 'Ваше замовлення в :store скасовано: :reason.', + 'store_credit' => 'Вашу оплату повернено як кредит магазину; використайте код :code у наступному замовленні.', + 'refunded' => 'Вашу оплату повернено, кошти надійдуть протягом кількох днів.', + 'will_refund' => 'Якщо з вас списали кошти, їх буде повернено.', + 'no_action' => 'Жодних додаткових дій не потрібно.', + ], + 'order_chat_message' => [ + 'title' => ':name надіслав повідомлення', + 'default_name' => 'Ваш водій', + 'photo' => 'Надіслав фото', + ], + 'order_completed' => [ + 'subject' => 'Ваше замовлення з :store доставлено', + 'body' => 'Ваше замовлення з :store доставлено, смачного!', + ], + 'order_driver_assigned' => [ + 'subject' => 'Ваш водій: :driver', + 'body' => 'До вашого замовлення з :store призначено водія, він уже їде по замовлення', + 'subject_booking' => 'Ваш виконавець: :driver', + 'body_booking' => ':driver буде вашим виконавцем для бронювання в :store.', + ], + 'order_enroute' => [ + 'subject' => 'Ваше замовлення з :store уже в дорозі!', + 'body' => 'Ваше замовлення з :store забрали, ми повідомимо, коли воно буде поруч', + ], + 'order_item_unavailable' => [ + 'default_item' => 'Товар', + 'subject' => ':item недоступний', + 'body' => ':item з вашого замовлення недоступний. :store замінить або прибере його та скоригує суму, якщо вона зміниться.', + 'body_booking' => ':item з вашого бронювання недоступний. :store замінить або прибере його та скоригує суму, якщо вона зміниться.', + ], + 'order_nearby' => [ + 'subject' => 'Ваше замовлення вже поруч!', + 'body' => 'Ваше замовлення з :store прибуде через :time', + ], + 'order_preparing' => [ + 'subject' => 'Ваше замовлення в :store готується!', + 'body' => 'Ми почали готувати ваше замовлення.', + ], + 'order_ready' => [ + 'subject' => 'Ваше замовлення в :store готове до самовивозу!', + 'body' => 'Можете забрати своє замовлення.', + ], + 'order_updated' => [ + 'subject' => 'Ваше замовлення в :store оновлено', + 'subject_booking' => 'Ваше бронювання в :store оновлено', + 'subject_moved' => 'Ваше бронювання в :store перенесено', + 'body' => 'Ваше замовлення в :store оновлено.', + 'booking_time' => 'Ваше бронювання тепер :when.', + 'booking_time_named' => ':service тепер :when.', + 'booking_time_range' => ':start до :end', + 'refunded' => 'Різницю повернено.', + 'store_credit' => 'Різницю додано як кредит магазину: використайте код :code у наступному замовленні.', + 'balance_due' => 'Залишилася сума до доплати.', + 'balance_due_link' => 'Залишилася сума до доплати: :link', + ], +]; diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index 88183848..9f3cc953 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -145,7 +145,7 @@ public function applyPromotionCode(string $cartId, Request $request) { $code = PromotionCode::normalize($request->input('code')); if ($code === '') { - return response()->apiError('A promotion code is required.'); + return response()->apiError(__('storefront::checkout.promotion_code_required')); } $cart = $this->retrieveCart($cartId); @@ -154,7 +154,7 @@ public function applyPromotionCode(string $cartId, Request $request) $rejection = collect($result->rejected)->firstWhere('code', $code); if ($rejection && $rejection['reason'] !== PromotionEngine::REASON_NOT_COMBINABLE) { - return response()->apiError('Promotion code "' . $code . '" cannot be applied (' . $rejection['reason'] . ').', 400, ['reason' => $rejection['reason']]); + return response()->apiError(__('storefront::checkout.promotion_code_rejected', ['code' => $code, 'reason' => $rejection['reason']]), 400, ['reason' => $rejection['reason']]); } $cart->setPromotionCodes($codes)->save(); diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index bac42521..b631c7bc 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -53,8 +53,6 @@ class CheckoutController extends Controller { - private const STRIPE_AUTHENTICATION_ERROR = 'Stripe gateway authentication failed. Verify the configured secret key.'; - private static function hasStripeSecret(Gateway $gateway): bool { $secretKey = data_get($gateway, 'config.secret_key'); @@ -71,7 +69,7 @@ private static function stripeAuthenticationError(Gateway $gateway, string $oper 'exception' => StripeAuthenticationException::class, ]); - return response()->apiError(self::STRIPE_AUTHENTICATION_ERROR); + return response()->apiError(__('storefront::checkout.stripe_authentication_failed')); } protected static function qpayForGateway(Gateway $gateway): QPay @@ -147,12 +145,12 @@ protected function validateMarketplaceCart(Cart $cart) $items = collect($cart->items); if ($items->isEmpty()) { - return response()->apiError('The cart is empty.', 422); + return response()->apiError(__('storefront::checkout.cart_empty'), 422); } $storeIds = $items->pluck('store_id')->filter()->unique()->values(); if ($storeIds->count() !== $items->pluck('store_id')->unique()->count()) { - return response()->apiError('Every marketplace cart item must identify its store.', 422); + return response()->apiError(__('storefront::checkout.cart_item_store_missing'), 422); } // Active members only: a suspended or pending store can't take orders through the network. @@ -162,17 +160,17 @@ protected function validateMarketplaceCart(Cart $cart) ->keyBy('public_id'); if ($stores->count() !== $storeIds->count()) { - return response()->apiError('The cart contains a store outside this marketplace.', 403); + return response()->apiError(__('storefront::checkout.cart_store_outside_marketplace'), 403); } if ($stores->contains(fn (Store $store) => !$store->online)) { - return response()->apiError('A store in this cart is currently offline.', 422); + return response()->apiError(__('storefront::checkout.cart_store_offline'), 422); } $network = Network::select(['uuid', 'options'])->where('uuid', $networkUuid)->first(); $multiCartEnabled = data_get($network, 'options.multi_cart_enabled') === true; if ($storeIds->count() > 1 && !$multiCartEnabled) { - return response()->apiError('This marketplace only supports one store per cart.', 422); + return response()->apiError(__('storefront::checkout.marketplace_single_store_only'), 422); } $products = Product::whereIn('public_id', $items->pluck('product_id')->filter()->unique()) @@ -191,15 +189,15 @@ protected function validateMarketplaceCart(Cart $cart) $product = $products->get($item->product_id ?? null); $location = $locations->get($item->store_location_id ?? null); if (!$store || !$product || $product->store_uuid !== $store->uuid) { - return response()->apiError('A product in this cart is no longer available from its store.', 422); + return response()->apiError(__('storefront::checkout.cart_product_unavailable'), 422); } if (!$location || $location->store_uuid !== $store->uuid) { - return response()->apiError('A store location in this cart is no longer valid.', 422); + return response()->apiError(__('storefront::checkout.cart_store_location_invalid'), 422); } } if ($products->pluck('currency')->filter()->unique()->count() > 1) { - return response()->apiError('Marketplace carts cannot combine different currencies.', 422); + return response()->apiError(__('storefront::checkout.cart_mixed_currencies'), 422); } return null; @@ -279,7 +277,7 @@ protected static function resolveCheckoutCustomer(?string $customerId) : $customerId; if ($authenticated->public_id !== $normalized) { - return response()->apiError('Customer does not match the authenticated session.', 403); + return response()->apiError(__('storefront::checkout.customer_session_mismatch'), 403); } } @@ -331,7 +329,7 @@ public function beforeCheckout(InitializeCheckoutRequest $request) } if (!$gateway) { - return response()->apiError('No gateway configured!'); + return response()->apiError(__('storefront::checkout.gateway_not_configured')); } // handle checkout initialization based on gateway @@ -344,7 +342,7 @@ public function beforeCheckout(InitializeCheckoutRequest $request) return static::initializeQPayCheckout($customer, $gateway, $serviceQuote, $cart, $checkoutOptions, $request); } - return response()->apiError('Unable to initialize checkout!'); + return response()->apiError(__('storefront::checkout.checkout_initialize_failed')); } public static function initializeCashCheckout(Contact $customer, Gateway $gateway, ?ServiceQuote $serviceQuote, Cart $cart, $checkoutOptions, $request) @@ -412,7 +410,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // check for secret key first if (!static::hasStripeSecret($gateway)) { - return response()->apiError('Gateway not configured correctly!'); + return response()->apiError(__('storefront::checkout.gateway_misconfigured')); } // Set the stipre secret key from gateway @@ -452,7 +450,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate return static::stripeAuthenticationError($gateway, 'recreate_customer'); } } else { - return response()->apiError('Error from Stripe: ' . $errorMessage); + return response()->apiError(__('storefront::checkout.stripe_error', ['message' => $errorMessage])); } } @@ -512,11 +510,11 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques $gateway = Storefront::findGateway('stripe'); if (!$gateway) { - return response()->apiError('Stripe not setup.'); + return response()->apiError(__('storefront::checkout.stripe_not_setup')); } if (!static::hasStripeSecret($gateway)) { - return response()->apiError('Gateway not configured correctly!'); + return response()->apiError(__('storefront::checkout.gateway_misconfigured')); } $customer = static::resolveCheckoutCustomer($customerId); @@ -609,7 +607,7 @@ public function updateStripePaymentIntent(Request $request) // Retrieve the gateway (stripe) $gateway = Storefront::findGateway('stripe'); if (!$gateway) { - return response()->apiError('No stripe gateway configured!'); + return response()->apiError(__('storefront::checkout.stripe_gateway_not_configured')); } // Retrieve and validate necessary models @@ -617,7 +615,7 @@ public function updateStripePaymentIntent(Request $request) // Cart::retrieve() always returns either the persisted cart or a new cart instance. // @codeCoverageIgnoreStart if (!$cart) { - return response()->apiError('Invalid cart ID provided'); + return response()->apiError(__('storefront::checkout.invalid_cart_id')); } // @codeCoverageIgnoreEnd @@ -626,7 +624,7 @@ public function updateStripePaymentIntent(Request $request) return $customer; } if (!$customer) { - return response()->apiError('Invalid customer ID provided'); + return response()->apiError(__('storefront::checkout.invalid_customer_id')); } $serviceQuote = ServiceQuote::select(['amount', 'meta', 'uuid', 'public_id']) @@ -645,7 +643,7 @@ public function updateStripePaymentIntent(Request $request) // Check for Stripe secret key if (!static::hasStripeSecret($gateway)) { - return response()->apiError('Gateway not configured correctly!'); + return response()->apiError(__('storefront::checkout.gateway_misconfigured')); } // Set Stripe API key @@ -666,13 +664,13 @@ public function updateStripePaymentIntent(Request $request) } catch (StripeAuthenticationException $e) { return static::stripeAuthenticationError($gateway, 'retrieve_payment_intent'); } catch (\Exception $e) { - return response()->apiError('Failed to retrieve PaymentIntent: ' . $e->getMessage()); + return response()->apiError(__('storefront::checkout.payment_intent_retrieve_failed', ['message' => $e->getMessage()])); } // Check if PaymentIntent is in a modifiable state $modifiableStatuses = ['requires_payment_method', 'requires_confirmation', 'requires_action', 'processing']; if (!in_array($paymentIntent->status, $modifiableStatuses)) { - return response()->apiError('PaymentIntent cannot be updated at this stage.'); + return response()->apiError(__('storefront::checkout.payment_intent_not_updatable')); } // Prepare the updated data @@ -687,7 +685,7 @@ public function updateStripePaymentIntent(Request $request) } catch (StripeAuthenticationException $e) { return static::stripeAuthenticationError($gateway, 'update_payment_intent'); } catch (\Exception $e) { - return response()->apiError('Failed to update PaymentIntent: ' . $e->getMessage()); + return response()->apiError(__('storefront::checkout.payment_intent_update_failed', ['message' => $e->getMessage()])); } // If payment intent has a payment method set already update for the customer @@ -706,7 +704,7 @@ public function updateStripePaymentIntent(Request $request) } catch (StripeAuthenticationException $e) { return static::stripeAuthenticationError($gateway, 'update_ephemeral_key'); } catch (\Exception $e) { - return response()->apiError('Failed to create ephemeral key: ' . $e->getMessage()); + return response()->apiError(__('storefront::checkout.ephemeral_key_failed', ['message' => $e->getMessage()])); } $attributes = [ @@ -730,7 +728,7 @@ public function updateStripePaymentIntent(Request $request) // rather than adding an unlinked one. $checkout = Checkout::where('stripe_payment_intent_id', $paymentIntent->id)->first(); if ($checkout && $checkout->owner_uuid !== $customer->uuid) { - return response()->apiError('PaymentIntent belongs to another checkout.', 422); + return response()->apiError(__('storefront::checkout.payment_intent_other_checkout'), 422); } if ($checkout) { // Its promotion uses and stock are reserved again below at the new price. @@ -773,7 +771,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // check for secret key first if (!isset($gateway->config->username)) { - return response()->apiError('Gateway not configured correctly!'); + return response()->apiError(__('storefront::checkout.gateway_misconfigured')); } // Create qpay instance @@ -966,7 +964,7 @@ public function captureQPayCallback(Request $request) } else { $data['error'] = [ 'error' => 'PAYMENT_NOT_PAID', - 'message' => 'Payment has not been paid!', + 'message' => __('storefront::checkout.payment_not_paid'), ]; } @@ -1372,7 +1370,7 @@ public function captureOrder(CaptureOrderRequest $request) if (!$request->attributes->get('storefront_checkout_lock_held')) { $checkout = Checkout::where('token', $request->input('token'))->first(); if (!$checkout) { - return response()->apiError('Checkout session not found.'); + return response()->apiError(__('storefront::checkout.checkout_session_not_found')); } return $this->captureOrderWithLock($checkout, $request); @@ -1390,7 +1388,7 @@ public function captureOrder(CaptureOrderRequest $request) // get checkout data to create order $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first(); if (!$checkout) { - return response()->apiError('Checkout session not found.'); + return response()->apiError(__('storefront::checkout.checkout_session_not_found')); } // The customer's note: sent with the capture, or saved on the checkout before paying @@ -1399,7 +1397,7 @@ public function captureOrder(CaptureOrderRequest $request) $about = Storefront::about(); if (!$about) { - return response()->apiError('No storefront in request to capture order!'); + return response()->apiError(__('storefront::checkout.capture_storefront_missing')); } $customer = $checkout->owner; @@ -1421,7 +1419,7 @@ public function captureOrder(CaptureOrderRequest $request) // if cart is null then cart has either been deleted or expired if (!$cart) { - return response()->apiError('Cart expired'); + return response()->apiError(__('storefront::checkout.cart_expired')); } // $amount = $checkout->amount ?? ($checkout->is_pickup ? $cart->subtotal : $cart->subtotal + $serviceQuote->amount); @@ -1435,7 +1433,7 @@ public function captureOrder(CaptureOrderRequest $request) // it before they capture. A capture request carrying only the checkout token, which // the customer's app holds, must not create an unpaid order. if ($gateway && $gateway->isQPayGateway && !$checkout->getOption('qpay_payment')) { - return response()->apiError('QPay payment has not been completed.', 402); + return response()->apiError(__('storefront::checkout.qpay_payment_incomplete'), 402); } if ($gateway && $gateway->isStripeGateway) { @@ -1468,7 +1466,7 @@ public function captureOrder(CaptureOrderRequest $request) // super rare condition if (!$store) { - return response()->apiError('No storefront in request to capture order!'); + return response()->apiError(__('storefront::checkout.capture_storefront_missing')); } // prepare for integrated vendor order if applicable @@ -1715,11 +1713,11 @@ public function captureOrder(CaptureOrderRequest $request) protected function verifyStripePaymentForCheckout(Checkout $checkout, Gateway $gateway, ?Contact $customer, int $amount, ?string $currency): array|JsonResponse { if (!$checkout->stripe_payment_intent_id) { - return response()->apiError('Stripe PaymentIntent is not linked to this checkout.', 422); + return response()->apiError(__('storefront::checkout.stripe_payment_intent_not_linked'), 422); } if (!static::hasStripeSecret($gateway)) { - return response()->apiError('Gateway not configured correctly!'); + return response()->apiError(__('storefront::checkout.gateway_misconfigured')); } \Stripe\Stripe::setApiKey($gateway->config->secret_key); @@ -1735,15 +1733,15 @@ protected function verifyStripePaymentForCheckout(Checkout $checkout, Gateway $g 'exception' => get_class($e), ]); - return response()->apiError('Unable to verify Stripe payment.', 502); + return response()->apiError(__('storefront::checkout.stripe_payment_verify_failed'), 502); } if ($paymentIntent->status !== 'succeeded') { - return response()->apiError('Stripe payment has not been completed.', 402); + return response()->apiError(__('storefront::checkout.stripe_payment_incomplete'), 402); } if (!is_string($currency) || trim($currency) === '') { - return response()->apiError('Stripe payment does not match this checkout.', 422); + return response()->apiError(__('storefront::checkout.stripe_payment_mismatch'), 422); } $stripeCustomerId = is_object($paymentIntent->customer) ? $paymentIntent->customer->id : $paymentIntent->customer; @@ -1766,7 +1764,7 @@ protected function verifyStripePaymentForCheckout(Checkout $checkout, Gateway $g 'payment_intent_id' => $paymentIntent->id, ]); - return response()->apiError('Stripe payment does not match this checkout.', 422); + return response()->apiError(__('storefront::checkout.stripe_payment_mismatch'), 422); } return [ @@ -1789,7 +1787,7 @@ protected function captureOrderWithLock(Checkout $checkout, CaptureOrderRequest return new OrderResource($checkout->order); } - return response()->apiError('Order capture is already in progress.', 409); + return response()->apiError(__('storefront::checkout.capture_in_progress'), 409); } try { @@ -1821,7 +1819,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) // get checkout data to create order $checkout = Checkout::where('token', $token)->with(['gateway', 'owner', 'serviceQuote', 'cart'])->first(); if (!$checkout) { - return response()->apiError('Checkout session not found.'); + return response()->apiError(__('storefront::checkout.checkout_session_not_found')); } // The customer's note: sent with the capture, or saved on the checkout before paying @@ -1852,7 +1850,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) } if (!$about) { - return response()->apiError('No network in request to capture order!'); + return response()->apiError(__('storefront::checkout.capture_network_missing')); } // prepare for integrated vendor order if applicable @@ -1872,7 +1870,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $originPlaces = collect($origins)->map(fn ($publicId) => Place::createFromMixed($publicId))->values(); $originStores = $originPlaces->map(fn ($pickup) => $pickup instanceof Place ? Storefront::getStoreFromLocation($pickup->uuid) : null)->values(); if ($originPlaces->isEmpty() || $originStores->contains(fn ($store) => !$store)) { - return response()->apiError('A store in this order could not be found, so it was not placed. Your payment has not been used.'); + return response()->apiError(__('storefront::checkout.order_store_not_found')); } // setup transaction meta @@ -2252,18 +2250,18 @@ public function updateCheckoutNotes(Request $request) { $token = $request->input('token'); if (!is_string($token) || $token === '') { - return response()->apiError('Missing checkout token.', 400); + return response()->apiError(__('storefront::checkout.checkout_token_missing'), 400); } $notes = $request->input('notes'); if ($notes !== null && !is_string($notes)) { - return response()->apiError('Notes must be text.', 422); + return response()->apiError(__('storefront::checkout.notes_must_be_text'), 422); } $checkout = Checkout::where('token', $token)->first(); $owner = session('storefront_network') ?? session('storefront_store'); if (!$checkout || !$owner || ($checkout->network_uuid ?? $checkout->store_uuid) !== $owner) { - return response()->apiError('Checkout session not found.', 404); + return response()->apiError(__('storefront::checkout.checkout_session_not_found'), 404); } $notes = static::cleanCustomerNotes($notes); @@ -2294,7 +2292,7 @@ public function getCheckoutStatus(Request $request) // Validate required parameters if (!$checkoutId || !$token) { return response()->json([ - 'error' => 'Missing required parameters: checkout and token', + 'error' => __('storefront::checkout.status_parameters_missing'), ], 400); } @@ -2307,7 +2305,7 @@ public function getCheckoutStatus(Request $request) if (!$checkout) { return response()->json([ - 'error' => 'Checkout not found', + 'error' => __('storefront::checkout.checkout_not_found'), ], 404); } @@ -2403,7 +2401,7 @@ public function getCheckoutStatus(Request $request) ]); return response()->json([ - 'error' => 'Failed to retrieve checkout status', + 'error' => __('storefront::checkout.checkout_status_failed'), 'message' => $e->getMessage(), ], 500); } @@ -2505,7 +2503,7 @@ protected static function applyPromotions(Cart $cart, ?ServiceQuote $serviceQuot $blocking = array_values(array_filter($result->rejected, fn ($rejection) => $rejection['reason'] !== PromotionEngine::REASON_NOT_COMBINABLE)); if ($blocking) { return response()->apiError( - 'Promotion code "' . $blocking[0]['code'] . '" cannot be applied (' . $blocking[0]['reason'] . ').', + __('storefront::checkout.promotion_code_rejected', ['code' => $blocking[0]['code'], 'reason' => $blocking[0]['reason']]), 400, ['promotions' => ['rejected' => $blocking]] ); @@ -2608,7 +2606,7 @@ protected static function reservePromotions(Checkout $checkout, $checkoutOptions $checkout->delete(); return response()->json([ - 'errors' => ['The time you picked for ' . $fullSlot . ' was just booked. Please pick another time.'], + 'errors' => [__('storefront::checkout.booking_slot_taken', ['product' => $fullSlot])], 'code' => 'booking_slot_unavailable', ], 422); } @@ -2619,7 +2617,7 @@ protected static function reservePromotions(Checkout $checkout, $checkoutOptions $checkout->delete(); return response()->json([ - 'errors' => ['Some items in your cart are no longer available.'], + 'errors' => [__('storefront::checkout.cart_items_unavailable')], 'code' => 'cart_items_unavailable', 'items' => $shortages, ], 422); diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 81071172..5eb06169 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -71,22 +71,22 @@ public function registerDevice(Request $request) $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to register device for cutomer'); + return response()->apiError(__('storefront::customers.not_authorized_register_device')); } $token = trim((string) $request->input('token')); $platform = StorefrontPushChannel::normalizePlatform($request->or(['platform', 'os'])); if ($token === '') { - return response()->apiError('A device token is required.'); + return response()->apiError(__('storefront::customers.device_token_required')); } if (!$platform) { - return response()->apiError('Device platform must be either ios or android.'); + return response()->apiError(__('storefront::customers.device_platform_invalid')); } if (!$customer->user_uuid) { - return response()->apiError('Unable to register device for customer without a user account.'); + return response()->apiError(__('storefront::customers.device_requires_user_account')); } // A token identifies one app install, so it always belongs to the customer who @@ -136,12 +136,12 @@ public function unregisterDevice(Request $request) $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to unregister device for customer'); + return response()->apiError(__('storefront::customers.not_authorized_unregister_device')); } $token = trim((string) $request->input('token')); if ($token === '') { - return response()->apiError('A device token is required.'); + return response()->apiError(__('storefront::customers.device_token_required')); } $deleted = UserDevice::where('token', $token)->where('user_uuid', $customer->user_uuid)->get()->each(fn (UserDevice $device) => $device->delete())->count(); @@ -164,19 +164,19 @@ public function unregisterDevice(Request $request) public function socketToken(Request $request) { if (!SocketToken::enabled()) { - return response()->apiError('Not found.', 404); + return response()->apiError(__('storefront::customers.not_found'), 404); } $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to create a socket token for customer.', 401); + return response()->apiError(__('storefront::customers.not_authorized_socket_token'), 401); } // A customer's token is only honoured by the storefront whose company the // customer belongs to, so a token minted against another company's key is refused. $storefront = Storefront::about(); if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { - return response()->apiError('Not authorized to create a socket token for customer.', 401); + return response()->apiError(__('storefront::customers.not_authorized_socket_token'), 401); } return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); @@ -202,7 +202,7 @@ public function orders(Request $request) $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers orders'); + return response()->apiError(__('storefront::customers.not_authorized_view_orders')); } $results = Order::queryWithRequest($request, function (&$query) use ($customer) { @@ -228,7 +228,7 @@ public function places(Request $request) $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers places'); + return response()->apiError(__('storefront::customers.not_authorized_view_places')); } $results = Place::queryWithRequest($request, function (&$query) use ($customer) { @@ -253,7 +253,7 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request // validate identity if ($mode === 'email' && !$isEmail) { - return response()->apiError('Invalid email provided for identity'); + return response()->apiError(__('storefront::customers.invalid_identity_email')); } // prepare phone number @@ -271,7 +271,7 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request // An identity that already has an account cannot ask for a creation code. Nothing // is checked or sent; the caller is told to sign in instead. if (static::accountExistsForIdentity($identity, $isEmail)) { - return response()->apiError('An account already exists for this ' . ($isEmail ? 'email address' : 'phone number') . '. Sign in instead.', 422); + return response()->apiError($isEmail ? __('storefront::customers.account_exists_for_email') : __('storefront::customers.account_exists_for_phone'), 422); } // A number that can never receive a text is refused before anything is written. @@ -296,7 +296,7 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request if ($isEmail) { VerificationCode::generateEmailVerificationFor($customer, 'storefront_create_customer', [ 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} verification code is {$verification->code}"; + return __('storefront::customers.verification_code_message', ['name' => $about->name, 'code' => $verification->code]); }, 'meta' => $meta, ]); @@ -304,7 +304,7 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request VerificationCode::generateSmsVerificationFor($customer, 'storefront_create_customer', [ 'hashed' => true, 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} verification code is {$verification->code}"; + return __('storefront::customers.verification_code_message', ['name' => $about->name, 'code' => $verification->code]); }, 'meta' => $meta, ]); @@ -316,7 +316,7 @@ public function requestCustomerCreationCode(VerifyCreateCustomerRequest $request } catch (\Twilio\Exceptions\RestException $e) { return response()->apiError($e->getMessage()); } catch (\Exception $e) { - return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error sending verification code.'); + return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : __('storefront::customers.error_sending_verification_code')); } } @@ -345,12 +345,12 @@ public function create(CreateCustomerRequest $request) } if (blank($identity)) { - return response()->apiError('An identity is required to create a customer.'); + return response()->apiError(__('storefront::customers.identity_required')); } $attemptKey = 'create|' . static::attemptIdentity($identity, $about); if ($this->codeAttemptsExceeded($attemptKey)) { - return response()->apiError('Too many attempts. Try again later.', 429); + return response()->apiError(__('storefront::customers.too_many_attempts'), 429); } // verify code: an app reviewer's App Review code, else the newest live code for this @@ -358,7 +358,7 @@ public function create(CreateCustomerRequest $request) if (!static::acceptsReviewerCode($identity, $code, $about) && !$this->verifyIdentityCode('storefront_create_customer', $identity, $code)) { $this->recordFailedCodeAttempt($attemptKey); - return response()->apiError('Invalid verification code provided!'); + return response()->apiError(__('storefront::customers.invalid_verification_code_provided')); } $this->clearCodeAttempts($attemptKey); @@ -466,7 +466,7 @@ public function update($id, UpdateContactRequest $request) { $authenticatedCustomer = $this->authenticatedCustomerForRequest($request); if (!$authenticatedCustomer) { - return response()->apiError('Not authorized to update customer.', 403); + return response()->apiError(__('storefront::customers.not_authorized_update_customer'), 403); } if (Str::startsWith($id, 'customer')) { @@ -477,11 +477,11 @@ public function update($id, UpdateContactRequest $request) try { $contact = Contact::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->apiError('Customer resource not found.'); + return response()->apiError(__('storefront::customers.customer_not_found')); } if ($contact->uuid !== $authenticatedCustomer->uuid) { - return response()->apiError('Not authorized to update customer.', 403); + return response()->apiError(__('storefront::customers.not_authorized_update_customer'), 403); } // get request input @@ -548,7 +548,7 @@ public function query(Request $request) { $customer = $this->authenticatedCustomerForRequest($request); if (!$customer) { - return response()->apiError('Not authorized to view customers.', 401); + return response()->apiError(__('storefront::customers.not_authorized_view_customers'), 401); } $results = Contact::queryWithRequestCached($request, function (&$query) use ($customer) { @@ -568,7 +568,7 @@ public function find($id) { $customer = $this->authenticatedCustomerForRequest(request()); if (!$customer) { - return response()->apiError('Not authorized to view customer.', 401); + return response()->apiError(__('storefront::customers.not_authorized_view_customer'), 401); } if (Str::startsWith($id, 'customer')) { @@ -579,11 +579,11 @@ public function find($id) try { $contact = Contact::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->apiError('Customer resource not found.'); + return response()->apiError(__('storefront::customers.customer_not_found')); } if ($contact->uuid !== $customer->uuid) { - return response()->apiError('Not authorized to view customer.', 403); + return response()->apiError(__('storefront::customers.not_authorized_view_customer'), 403); } // response the customer resource @@ -605,7 +605,7 @@ public function delete($id) try { $contact = Contact::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->apiError('Customer resource not found.'); + return response()->apiError(__('storefront::customers.customer_not_found')); } // delete the product @@ -635,7 +635,7 @@ public function login(Request $request) ->first(); if (!$user || !Hash::check($password, $user->password)) { - return response()->apiError('Authentication failed using password provided.', 401); + return response()->apiError(__('storefront::customers.password_authentication_failed'), 401); } // get the storefront or network logging in for @@ -682,7 +682,7 @@ public function loginWithPhone() // Without a phone in the request there is nothing to look up. Falling through would // compile to `phone IS NULL` and hand back an arbitrary phone-less user. if (!$phone) { - return response()->apiError('No customer with this phone # found.'); + return response()->apiError(__('storefront::customers.no_customer_with_phone')); } // get the storefront or network logging in for @@ -698,7 +698,7 @@ public function loginWithPhone() $user = User::where('phone', $phone)->whereNull('deleted_at')->withoutGlobalScopes()->first(); if (!$user) { - return response()->apiError('No customer with this phone # found.'); + return response()->apiError(__('storefront::customers.no_customer_with_phone')); } // Generate the verification token. @@ -712,7 +712,7 @@ public function loginWithPhone() // means a store without Twilio can still authenticate its customers. `method` // tells the client which channel actually carried the code. $messageCallback = function ($verification) use ($about) { - return "Your {$about->name} verification code is {$verification->code}"; + return __('storefront::customers.verification_code_message', ['name' => $about->name, 'code' => $verification->code]); }; $smsFailure = null; @@ -748,7 +748,7 @@ public function loginWithPhone() // With no email to fall back to, a refusal by the SMS guard is answered with its // own status so the app can tell "wait a minute" from "something broke". - return static::sendFailureResponse($smsFailure, 'Unable to send verification code.'); + return static::sendFailureResponse($smsFailure, __('storefront::customers.unable_to_send_verification_code')); } /** @@ -783,7 +783,7 @@ public function loginWithApple(Request $request) $appleUserId = $request->input('appleUserId'); if (!$identityToken || !$authorizationCode) { - return response()->apiError('Missing required Apple authentication parameters.', 400); + return response()->apiError(__('storefront::customers.apple_parameters_missing'), 400); } try { @@ -807,7 +807,7 @@ public function loginWithApple(Request $request) } if (!$isValid) { - return response()->apiError('Apple ID authentication is not valid.', 400); + return response()->apiError(__('storefront::customers.apple_authentication_invalid'), 400); } // Check if the user exists in the system @@ -946,14 +946,14 @@ public function loginWithGoogle(Request $request) $idToken = $request->input('idToken'); $clientId = $request->input('clientId'); if (!$idToken || !$clientId) { - return response()->apiError('Missing required Google authentication parameters.', 400); + return response()->apiError(__('storefront::customers.google_parameters_missing'), 400); } try { // Verify the Google ID token using the utility function $payload = $this->verifyGoogleIdentity($idToken, $clientId); if (!$payload) { - return response()->apiError('Google Sign-In authentication is not valid.', 400); + return response()->apiError(__('storefront::customers.google_authentication_invalid'), 400); } // Extract user details from the payload @@ -1106,7 +1106,7 @@ public function verifyCode(Request $request) // `phone IS NULL OR email IS NULL` and pick an arbitrary user to test the code // against. if (blank($identity)) { - return response()->apiError('Unable to verify code.'); + return response()->apiError(__('storefront::customers.unable_to_verify_code')); } // get the storefront or network logging in for @@ -1114,7 +1114,7 @@ public function verifyCode(Request $request) $attemptKey = 'verify|' . static::attemptIdentity($identity, $about); if ($this->codeAttemptsExceeded($attemptKey)) { - return response()->apiError('Too many attempts. Try again later.', 429); + return response()->apiError(__('storefront::customers.too_many_attempts'), 429); } // This storefront's App Review code signs the review number in, creating the shared @@ -1127,14 +1127,14 @@ public function verifyCode(Request $request) if (!$user) { $this->recordFailedCodeAttempt($attemptKey); - return response()->apiError('Unable to verify code.'); + return response()->apiError(__('storefront::customers.unable_to_verify_code')); } // find and verify code: the newest live code for this user, checked and used up if (!$isAppReviewer && !$this->verifySubjectCode($for, $user->uuid, $code) && !static::isReviewAccountBypass($identity, $code)) { $this->recordFailedCodeAttempt($attemptKey); - return response()->apiError('Invalid verification code!'); + return response()->apiError(__('storefront::customers.invalid_verification_code')); } $this->clearCodeAttempts($attemptKey); @@ -1359,12 +1359,12 @@ public function getStripeEphemeralKey(Request $request) { $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers places'); + return response()->apiError(__('storefront::customers.not_authorized_view_places')); } $gateway = Storefront::findGateway('stripe'); if (!$gateway) { - return response()->apiError('Stripe not setup.'); + return response()->apiError(__('storefront::customers.stripe_not_setup')); } \Stripe\Stripe::setApiKey($gateway->config->secret_key); @@ -1395,12 +1395,12 @@ public function getStripeSetupIntent(Request $request) { $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers places'); + return response()->apiError(__('storefront::customers.not_authorized_view_places')); } $gateway = Storefront::findGateway('stripe'); if (!$gateway) { - return response()->apiError('Stripe not setup.'); + return response()->apiError(__('storefront::customers.stripe_not_setup')); } \Stripe\Stripe::setApiKey($gateway->config->secret_key); @@ -1431,23 +1431,23 @@ public function startAccountClosure(Request $request) { $about = Storefront::about(['company_uuid']); if (!$about) { - return response()->apiError('Storefront not found.'); + return response()->apiError(__('storefront::customers.storefront_not_found')); } $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers places'); + return response()->apiError(__('storefront::customers.not_authorized_view_places')); } // Get the user account for the contact/customer $user = User::where(['uuid' => $customer->user_uuid])->first(); if (!$user) { - return response()->apiError('Customer user account not found.'); + return response()->apiError(__('storefront::customers.customer_user_not_found')); } // Check for phone or email if (!$user->phone && !$user->email) { - return response()->apiError('Customer account must have a valid email or phone number linked.'); + return response()->apiError(__('storefront::customers.customer_contact_required')); } // The identity the code is filed under MUST match what confirmAccountClosure looks @@ -1457,7 +1457,7 @@ public function startAccountClosure(Request $request) // fallback below breaks that coupling, so it is made explicit. $identity = $user->phone ?? $user->email; $messageCallback = function ($verification) use ($about) { - return "Your {$about->name} account closure verification code is {$verification->code}"; + return __('storefront::customers.account_closure_code_message', ['name' => $about->name, 'code' => $verification->code]); }; // An app reviewer confirms with the store's App Review code; nothing is sent. @@ -1493,7 +1493,7 @@ public function startAccountClosure(Request $request) if (!$sent && $user->email) { try { VerificationCode::generateEmailVerificationFor($user, 'storefront_account_closure', [ - 'subject' => $about->name . ' account closure request', + 'subject' => __('storefront::customers.account_closure_subject', ['name' => $about->name]), 'messageCallback' => $messageCallback, 'meta' => ['identity' => $identity], ]); @@ -1509,7 +1509,7 @@ public function startAccountClosure(Request $request) return response()->json(['status' => 'OK']); } - return static::sendFailureResponse($smsFailure, 'Unable to send account closure verification code.'); + return static::sendFailureResponse($smsFailure, __('storefront::customers.unable_to_send_account_closure_code')); } public function confirmAccountClosure(Request $request) @@ -1517,25 +1517,25 @@ public function confirmAccountClosure(Request $request) $code = $request->input('code'); $about = Storefront::about(['company_uuid']); if (!$about) { - return response()->apiError('Storefront not found.'); + return response()->apiError(__('storefront::customers.storefront_not_found')); } $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Not authorized to view customers places'); + return response()->apiError(__('storefront::customers.not_authorized_view_places')); } // Get the user account for the contact/customer $user = User::where(['uuid' => $customer->user_uuid])->first(); if (!$user) { - return response()->apiError('Customer user account not found.'); + return response()->apiError(__('storefront::customers.customer_user_not_found')); } // Get verification identity $identity = $user->phone ?? $user->email; if ($this->codeAttemptsExceeded('closure|' . $customer->uuid)) { - return response()->apiError('Too many attempts. Try again later.', 429); + return response()->apiError(__('storefront::customers.too_many_attempts'), 429); } // verify account closure code @@ -1543,7 +1543,7 @@ public function confirmAccountClosure(Request $request) if (!$isAppReviewer && !$this->verifyIdentityCode('storefront_account_closure', $identity, $code) && !static::isReviewAccountBypass($identity, $code)) { $this->recordFailedCodeAttempt('closure|' . $customer->uuid); - return response()->apiError('Invalid verification code provided!'); + return response()->apiError(__('storefront::customers.invalid_verification_code_provided')); } $this->clearCodeAttempts('closure|' . $customer->uuid); @@ -1564,7 +1564,7 @@ public function confirmAccountClosure(Request $request) return response()->apiError($e->getMessage()); } - return response()->apiError('An uknown error occured attempting to close customer account.'); + return response()->apiError(__('storefront::customers.account_closure_failed')); } /** @@ -1578,19 +1578,19 @@ public function requestPhoneVerification(Request $request) $phone = static::phone($request->input('phone')); if (!$customer) { - return response()->apiError('Not authorized to request phone verification.'); + return response()->apiError(__('storefront::customers.not_authorized_request_phone_verification')); } // Use the user associated with the contact $user = User::where(['uuid' => $customer->user_uuid])->first(); if (!$user) { - return response()->apiError('No user associated with this customer.'); + return response()->apiError(__('storefront::customers.no_user_for_customer')); } // No phone to verify. This used to arrive as the literal '+' and get as far as the // SMS provider before failing with a credentials error. if (!$phone) { - return response()->apiError('A phone number is required to request verification.'); + return response()->apiError(__('storefront::customers.phone_required_for_verification')); } $about = Storefront::about(); @@ -1601,7 +1601,7 @@ public function requestPhoneVerification(Request $request) $existingUser = AppReview::isReviewPhone($phone) ? null : $this->findExistingUserByPhone($phone, $user->uuid); if ($existingUser) { - return response()->apiError('This phone number is already associated with another account.'); + return response()->apiError(__('storefront::customers.phone_already_in_use')); } // An app reviewer verifies with the store's App Review code (or a self-hosted review @@ -1623,7 +1623,7 @@ public function requestPhoneVerification(Request $request) 'hashed' => true, 'phone' => $phone, 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} verification code is {$verification->code}"; + return __('storefront::customers.verification_code_message', ['name' => $about->name, 'code' => $verification->code]); }, 'meta' => ['phone' => $phone], // Store the phone number in meta ]); @@ -1642,7 +1642,7 @@ public function requestPhoneVerification(Request $request) // detail while telling them nothing they can act on. Unlike customer login // and account closure, there is no email fallback here — verifying a phone // number by email would not verify anything. - return response()->apiError('Unable to send phone verification code.'); + return response()->apiError(__('storefront::customers.unable_to_send_phone_verification_code')); } } @@ -1666,17 +1666,17 @@ public function verifyPhoneNumber(Request $request) $code = $request->input('code'); if (!$customer) { - return response()->apiError('Not authorized to verify phone number.'); + return response()->apiError(__('storefront::customers.not_authorized_verify_phone')); } $user = $customer->user; if (!$user) { - return response()->apiError('No user associated with this customer.'); + return response()->apiError(__('storefront::customers.no_user_for_customer')); } if ($this->codeAttemptsExceeded('verify-phone|' . $user->uuid)) { - return response()->apiError('Too many attempts. Try again later.', 429); + return response()->apiError(__('storefront::customers.too_many_attempts'), 429); } // Find the verification code: the newest live one for this user, checked and used up @@ -1696,7 +1696,7 @@ public function verifyPhoneNumber(Request $request) $this->recordFailedCodeAttempt('verify-phone|' . $user->uuid); - return response()->apiError('Invalid verification code!'); + return response()->apiError(__('storefront::customers.invalid_verification_code')); } $this->clearCodeAttempts('verify-phone|' . $user->uuid); @@ -1706,7 +1706,7 @@ public function verifyPhoneNumber(Request $request) // recoverable 400 into a 500. $phone = data_get($verificationCode->meta, 'phone'); if (!$phone) { - return response()->apiError('Verification code is not associated with a phone number.'); + return response()->apiError(__('storefront::customers.verification_code_not_for_phone')); } // Update user and contact diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index 2a29b929..4b042319 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -18,8 +18,8 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); $storeIds = static::storeUuidsForStorefront(); $networkId = session('storefront_network'); if (empty($storeIds) && !$networkId) { @@ -80,13 +80,13 @@ public function find($id) try { $foodTruck = FoodTruck::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->error('Food Truck resource not found.'); + return response()->error(__('storefront::api.food_trucks.not_found')); } // Only a truck of this storefront: its own, a network member store's, or one the network runs itself. $ownedByNetwork = session('storefront_network') && $foodTruck->network_uuid === session('storefront_network'); if (!$ownedByNetwork && !in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { - return response()->error('Food Truck resource not found.', 404); + return response()->error(__('storefront::api.food_trucks.not_found'), 404); } $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index 45c2a8fe..90558170 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -45,7 +45,7 @@ protected static function resolveReferencePoint($location): ?Point public function stores(Request $request) { if (session('storefront_store')) { - return response()->error('Stores cannot have stores!'); + return response()->error(__('storefront::api.networks.stores_cannot_have_stores')); } $sort = $request->input('sort', false); @@ -212,7 +212,7 @@ public function storeLocations(Request $request) $networkUuid = session('storefront_network'); if (!$storeUuid && !$networkUuid) { - return response()->error('Store locations require a storefront context!'); + return response()->error(__('storefront::api.networks.locations_require_storefront')); } $limit = $request->input('limit', 30); @@ -308,7 +308,7 @@ public function tags(Request $request) $networkUuid = session('storefront_network'); if (!$storeUuid && !$networkUuid) { - return response()->error('Tags require a storefront context!'); + return response()->error(__('storefront::api.networks.tags_require_storefront')); } $tags = []; diff --git a/server/src/Http/Controllers/v1/NotificationController.php b/server/src/Http/Controllers/v1/NotificationController.php index fcf9eb80..61960a33 100644 --- a/server/src/Http/Controllers/v1/NotificationController.php +++ b/server/src/Http/Controllers/v1/NotificationController.php @@ -28,7 +28,7 @@ public function query(Request $request) { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to view customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_view'), 401); } $limit = min(max((int) $request->input('limit', 25), 1), 100); @@ -52,7 +52,7 @@ public function unreadCount() { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to view customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_view'), 401); } return response()->json(['count' => $this->inbox($customer)->whereNull('read_at')->count()]); @@ -62,12 +62,12 @@ public function find(string $id) { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to view customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_view'), 401); } $notification = $this->inbox($customer)->where('id', $id)->first(); if (!$notification) { - return response()->apiError('Notification not found.', 404); + return response()->apiError(__('storefront::api.notifications.not_found'), 404); } return new CustomerNotification($notification); @@ -77,12 +77,12 @@ public function markAsRead(string $id) { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to update customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_update'), 401); } $notification = $this->inbox($customer)->where('id', $id)->first(); if (!$notification) { - return response()->apiError('Notification not found.', 404); + return response()->apiError(__('storefront::api.notifications.not_found'), 404); } if ($notification->read_at === null) { @@ -96,7 +96,7 @@ public function markAllAsRead() { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to update customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_update'), 401); } $updated = $this->inbox($customer)->whereNull('read_at')->update(['read_at' => now()]); @@ -108,12 +108,12 @@ public function delete(string $id) { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to delete customer notifications', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_delete'), 401); } $deleted = $this->inbox($customer)->where('id', $id)->delete(); if (!$deleted) { - return response()->apiError('Notification not found.', 404); + return response()->apiError(__('storefront::api.notifications.not_found'), 404); } return response()->json(['status' => 'OK', 'id' => $id, 'deleted' => true]); @@ -123,7 +123,7 @@ public function getPreferences() { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to view notification preferences', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_view_preferences'), 401); } return response()->json(NotificationPreferences::for($customer)); @@ -133,12 +133,12 @@ public function updatePreferences(Request $request) { $customer = $this->customer(); if (!$customer) { - return response()->apiError('Not authorized to update notification preferences', 401); + return response()->apiError(__('storefront::api.notifications.not_authorized_update_preferences'), 401); } foreach (array_keys(NotificationPreferences::DEFAULTS) as $key) { if ($request->has($key) && filter_var($request->input($key), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) === null) { - return response()->apiError('The ' . $key . ' preference must be true or false.'); + return response()->apiError(__('storefront::api.notifications.preference_must_be_boolean', ['key' => $key])); } } diff --git a/server/src/Http/Controllers/v1/OrderChatController.php b/server/src/Http/Controllers/v1/OrderChatController.php index a94df926..58202b91 100644 --- a/server/src/Http/Controllers/v1/OrderChatController.php +++ b/server/src/Http/Controllers/v1/OrderChatController.php @@ -70,14 +70,14 @@ public function requestAddressChange(Request $request, string $id) [$order, $customer, $channel] = $context; if (OrderChat::isClosed($order) || $order->getMeta('is_pickup')) { - return response()->apiError('The address of this order can no longer change.', 422); + return response()->apiError(__('storefront::api.chat.address_locked'), 422); } $place = null; if ($request->filled('place')) { $place = Place::where('owner_uuid', $customer->uuid)->where(fn ($query) => $query->where('public_id', $request->input('place'))->orWhere('uuid', $request->input('place')))->first(); if (!$place) { - return response()->apiError('Place not found.', 404); + return response()->apiError(__('storefront::api.chat.place_not_found'), 404); } } @@ -123,7 +123,7 @@ public function messages(Request $request, string $id) if ($request->filled('before')) { $before = ChatMessage::where('chat_channel_uuid', $channel->uuid)->where('public_id', $request->input('before'))->first(); if (!$before) { - return response()->apiError('Message not found.', 404); + return response()->apiError(__('storefront::api.chat.message_not_found'), 404); } $query->where(fn ($older) => $older->where('created_at', '<', $before->created_at) ->orWhere(fn ($sameTime) => $sameTime->where('created_at', $before->created_at)->where('id', '<', $before->id))); @@ -146,12 +146,12 @@ public function send(SendOrderChatMessageRequest $request, string $id) [$order, $customer, $channel] = $context; if (OrderChat::isClosed($order)) { - return response()->json(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed'], 423); + return response()->json(['error' => __('storefront::api.chat.closed'), 'reason' => 'chat_closed'], 423); } $sender = OrderChat::participantFor($channel, $customer->user_uuid); if (!$sender) { - return response()->apiError('You are not part of this chat.', 403); + return response()->apiError(__('storefront::api.chat.not_participant'), 403); } $message = ChatMessage::create([ @@ -184,7 +184,7 @@ public function read(string $id) $participant = OrderChat::participantFor($channel, $customer->user_uuid); if (!$participant) { - return response()->apiError('You are not part of this chat.', 403); + return response()->apiError(__('storefront::api.chat.not_participant'), 403); } $unread = $channel->getUnreadMessagesForParticipant($participant); @@ -219,23 +219,23 @@ protected function contextFor(string $id, ?string $kind): array|JsonResponse $kind = OrderChat::normalizeKind($kind); $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Customer is not authenticated.', 401); + return response()->apiError(__('storefront::api.customer_not_authenticated'), 401); } $order = Order::where('public_id', $id)->with(['customer', 'driverAssigned'])->first(); if (!$order || !$this->belongsToStorefront($order)) { - return response()->apiError('Order not found.', 404); + return response()->apiError(__('storefront::api.orders.not_found'), 404); } if ($order->customer_uuid !== $customer->uuid) { - return response()->apiError('Not authorized to view this order.', 403); + return response()->apiError(__('storefront::api.orders.not_authorized_view'), 403); } // Once the order is finished the chat stays readable, but is not started any more. if ($kind === OrderChat::KIND_CUSTOMER) { $channel = OrderChat::isClosed($order) ? OrderChat::find($order, $kind) : OrderChat::openStore($order); if (!$channel) { - return response()->json(['error' => 'This order has no chat with the store.', 'reason' => 'chat_unavailable'], 409); + return response()->json(['error' => __('storefront::api.chat.store_unavailable'), 'reason' => 'chat_unavailable'], 409); } return [$order, $customer, $channel]; @@ -244,7 +244,7 @@ protected function contextFor(string $id, ?string $kind): array|JsonResponse $channel = OrderChat::isClosed($order) ? OrderChat::find($order) : OrderChat::open($order); if (!$channel) { return response()->json([ - 'error' => 'You can message your driver once one is assigned to this order.', + 'error' => __('storefront::api.chat.driver_not_assigned'), 'reason' => 'driver_not_assigned', ], 409); } diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 244217b8..d7f0238f 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -47,17 +47,17 @@ public function completeOrderPickup(Request $request) { $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Customer is not authenticated.'); + return response()->apiError(__('storefront::api.customer_not_authenticated')); } $order = Order::where('public_id', $request->order)->whereNull('deleted_at')->with(['customer'])->first(); if (!$order) { - return response()->apiError('No order found.'); + return response()->apiError(__('storefront::api.orders.no_order_found')); } // Confirm the completion is done by the customer if ($order->customer_uuid !== $customer->uuid) { - return response()->apiError('Not authorized to pickup this order for completion.'); + return response()->apiError(__('storefront::api.orders.not_authorized_pickup')); } // Patch order config @@ -93,16 +93,16 @@ public function getStores(string $id) { $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Customer is not authenticated.'); + return response()->apiError(__('storefront::api.customer_not_authenticated')); } $order = Order::where('public_id', $id)->whereNull('deleted_at')->first(); if (!$order) { - return response()->apiError('No order found.', 404); + return response()->apiError(__('storefront::api.orders.no_order_found'), 404); } if ($order->customer_uuid !== $customer->uuid) { - return response()->apiError('Not authorized to view this order.', 403); + return response()->apiError(__('storefront::api.orders.not_authorized_view'), 403); } $related = array_values(array_filter((array) $order->getMeta('related_orders', []))); @@ -167,7 +167,7 @@ public function getActivityFlow(string $id) { $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Customer is not authenticated.'); + return response()->apiError(__('storefront::api.customer_not_authenticated')); } $order = Order::where('public_id', $id) @@ -175,11 +175,11 @@ public function getActivityFlow(string $id) ->first(); if (!$order) { - return response()->apiError('No order found.', 404); + return response()->apiError(__('storefront::api.orders.no_order_found'), 404); } if ($order->customer_uuid !== $customer->uuid) { - return response()->apiError('Not authorized to view this order.', 403); + return response()->apiError(__('storefront::api.orders.not_authorized_view'), 403); } return response()->json(OrderActivityFlow::forOrder($order)); @@ -201,7 +201,7 @@ public function getReceipt(Request $request) // Authenticate customer $customer = Storefront::getCustomerFromToken(); if (!$customer) { - return response()->apiError('Customer is not authenticated.'); + return response()->apiError(__('storefront::api.customer_not_authenticated')); } // Retrieve order @@ -211,12 +211,12 @@ public function getReceipt(Request $request) ->first(); if (!$order) { - return response()->apiError('No order found.'); + return response()->apiError(__('storefront::api.orders.no_order_found')); } // Authorize customer for this order if ($order->customer_uuid !== $customer->uuid) { - return response()->apiError('Not authorized to get receipt for this order.'); + return response()->apiError(__('storefront::api.orders.not_authorized_receipt')); } // Route to appropriate receipt handler based on payment method @@ -234,7 +234,7 @@ public function getReceipt(Request $request) default: return response()->json([ - 'message' => 'No receipt available for this payment method.', + 'message' => __('storefront::api.orders.receipt_unavailable'), 'payment_method' => $paymentMethod, ]); } @@ -260,13 +260,13 @@ private function getQpayEbarimtReceipt(Request $request, Order $order) $ebarimtReceiver = $request->input('ebarimt_receiver'); if ($ebarimtReceiverType === 'COMPANY' && empty($ebarimtReceiver)) { - return response()->apiError('Company registration number is required.'); + return response()->apiError(__('storefront::api.orders.company_registration_required')); } // Verify payment method is QPay $order->loadMissing('payload'); if ($order->payload && $order->payload->payment_method !== 'qpay') { - return response()->apiError('This order was not paid using QPay.'); + return response()->apiError(__('storefront::api.orders.not_paid_with_qpay')); } // Retrieve checkout record @@ -275,7 +275,7 @@ private function getQpayEbarimtReceipt(Request $request, Order $order) : null; if (!$checkout) { - return response()->apiError('No checkout found for this order.'); + return response()->apiError(__('storefront::api.orders.checkout_not_found')); } // Initialize QPay gateway @@ -323,7 +323,7 @@ protected function initializeQpayGateway() // Resolve gateway configuration $gateway = Storefront::findGateway('qpay'); if (!$gateway) { - return response()->apiError('QPay is not configured.'); + return response()->apiError(__('storefront::api.orders.qpay_not_configured')); } // Create QPay instance with credentials @@ -382,7 +382,7 @@ protected function createEbarimtReceipt(QPay $qpay, $payment, string $receiverTy // Check for API errors if (isset($ebarimt->error)) { return response()->apiError( - $ebarimt->error ?? $ebarimt->message ?? 'Unable to create ebarimt receipt' + $ebarimt->error ?? $ebarimt->message ?? __('storefront::api.orders.ebarimt_receipt_failed') ); } diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index 764ec8e9..7a0321f6 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -225,7 +225,7 @@ public function update($id, UpdateProductRequest $request) $product = Product::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { return response()->json([ - 'error' => 'Product not found.', + 'error' => __('storefront::api.products.not_found'), ], 404); } @@ -402,7 +402,7 @@ public function find($id) ->first(); if (!$product) { - return response()->error('Product resource not found.', 404); + return response()->error(__('storefront::api.products.resource_not_found'), 404); } // response the product resource @@ -423,7 +423,7 @@ public function slots(Request $request, string $id) ->first(); if (!$product || !$product->is_bookable) { - return response()->error('Bookable service not found.', 404); + return response()->error(__('storefront::api.products.bookable_service_not_found'), 404); } return response()->json([ @@ -444,7 +444,7 @@ public function delete($id) try { $product = Product::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->error('Product resource not found.'); + return response()->error(__('storefront::api.products.resource_not_found')); } // delete the product diff --git a/server/src/Http/Controllers/v1/PromotionController.php b/server/src/Http/Controllers/v1/PromotionController.php index c9840468..a1c5e0d2 100644 --- a/server/src/Http/Controllers/v1/PromotionController.php +++ b/server/src/Http/Controllers/v1/PromotionController.php @@ -53,7 +53,7 @@ public function find(string $id) ->first(); if (!$promotion || $promotion->availabilityAt() === Promotion::AVAILABILITY_INACTIVE) { - return response()->apiError('Promotion not found.', 404); + return response()->apiError(__('storefront::api.promotions.not_found'), 404); } return new PromotionResource($promotion); diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index 3a12b07f..f183e63b 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -43,7 +43,7 @@ protected function findScopedReview(string $id): ?Review }); }) ->when(session('storefront_network'), function ($query) { - $memberStoreUuids = Store::select('uuid')->activeInNetwork(session('storefront_network')); + $memberStoreUuids = Store::select('uuid')->activeInNetwork(session('storefront_network')); $memberProductUuids = Product::select('uuid')->whereIn('store_uuid', clone $memberStoreUuids); $query->where(function ($subjectQuery) use ($memberStoreUuids, $memberProductUuids) { $subjectQuery->whereIn('subject_uuid', $memberStoreUuids) @@ -127,7 +127,7 @@ public function query(Request $request) $store = $this->resolveStoreForContext($request->input('store')); if (!$store) { - return response()->json(['error' => 'Cannot find reviews for store'], 400); + return response()->json(['error' => __('storefront::api.reviews.cannot_find_for_store')], 400); } $results = Review::queryWithRequestCached($request, function (&$query) use ($store, $limit, $offset) { @@ -209,7 +209,7 @@ public function count(Request $request) $store = $this->resolveStoreForContext($request->input('store')); if (!$store) { - return response()->json(['error' => 'Cannot count reviews for store'], 400); + return response()->json(['error' => __('storefront::api.reviews.cannot_count_for_store')], 400); } foreach ($range as $rating) { @@ -237,7 +237,7 @@ public function find($id) throw new ModelNotFoundException(); } } catch (ModelNotFoundException $exception) { - return response()->error('Review resource not found.'); + return response()->error(__('storefront::api.reviews.not_found')); } // response the review resource @@ -254,16 +254,16 @@ public function eligibility(Request $request) { $subjectId = $request->input('subject'); if (!is_string($subjectId) || $subjectId === '') { - return response()->error('A subject is required.'); + return response()->error(__('storefront::api.reviews.subject_required')); } $subject = Utils::resolveSubject($subjectId); if (!$subject || !$this->subjectBelongsToContext($subject)) { - return response()->error('Invalid subject for review'); + return response()->error(__('storefront::api.reviews.invalid_subject')); } if (static::reviewsDisabled()) { - return response()->json(['can_review' => false, 'reason' => 'reviews_disabled', 'message' => 'Reviews are turned off.', 'order' => null, 'review' => null]); + return response()->json(['can_review' => false, 'reason' => 'reviews_disabled', 'message' => __('storefront::api.reviews.disabled'), 'order' => null, 'review' => null]); } $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); @@ -284,17 +284,17 @@ public function create(CreateReviewRequest $request) $bucket = $request->input('bucket', config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket'))); if (!$customer) { - return response()->error('Not authorized to create reviews'); + return response()->error(__('storefront::api.reviews.not_authorized_create')); } if (static::reviewsDisabled()) { - return response()->json(['error' => 'Reviews are turned off.', 'reason' => 'reviews_disabled'], 403); + return response()->json(['error' => __('storefront::api.reviews.disabled'), 'reason' => 'reviews_disabled'], 403); } $subject = Utils::resolveSubject($request->input('subject')); if (!$subject || !$this->subjectBelongsToContext($subject)) { - return response()->error('Invalid subject for review'); + return response()->error(__('storefront::api.reviews.invalid_subject')); } $eligibility = ReviewEligibility::check($customer, $subject, $request->input('order')); @@ -363,12 +363,12 @@ public function delete($id) throw new ModelNotFoundException(); } } catch (ModelNotFoundException $exception) { - return response()->error('Review resource not found.'); + return response()->error(__('storefront::api.reviews.not_found')); } $customer = Storefront::getCustomerFromToken(); if (!$customer || $review->customer_uuid !== $customer->uuid) { - return response()->error('Not authorized to delete review', 403); + return response()->error(__('storefront::api.reviews.not_authorized_delete'), 403); } // delete the review diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index ba12bb36..a7392af1 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -56,11 +56,11 @@ public function fromCart(GetServiceQuoteFromCart $request) } if (!$origin) { - return response()->error('No delivery origin!'); + return response()->error(__('storefront::checkout.delivery_origin_missing')); } if (!$destination) { - return response()->error('No delivery destination!'); + return response()->error(__('storefront::checkout.delivery_destination_missing')); } $currency = $cart->currency; @@ -73,7 +73,7 @@ public function fromCart(GetServiceQuoteFromCart $request) })->first(); if (!$integratedVendor) { - return response()->error('Integrated vendor not found!'); + return response()->error(__('storefront::checkout.integrated_vendor_not_found')); } try { @@ -136,7 +136,7 @@ public function fromCart(GetServiceQuoteFromCart $request) return new ServiceQuoteResource($serviceQuote); } - return response()->error('No service rates available!'); + return response()->error(__('storefront::checkout.service_rates_unavailable')); } foreach ($serviceRates as $serviceRate) { @@ -215,7 +215,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) // make sure destination is set if (!$destination) { - return response()->error('No delivery destination!'); + return response()->error(__('storefront::checkout.delivery_destination_missing')); } $currency = $cart->currency; @@ -259,7 +259,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) ->values(); if ($storeLocationIds->isEmpty() || $storeLocations->count() !== $storeLocationIds->count()) { - return response()->error('One or more store locations are unavailable for this marketplace.', 422); + return response()->error(__('storefront::checkout.marketplace_store_locations_unavailable'), 422); } $storeLocationsById = $storeLocations->keyBy('public_id'); @@ -275,7 +275,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) }); if ($hasMismatchedStore) { - return response()->error('One or more store locations are unavailable for this marketplace.', 422); + return response()->error(__('storefront::checkout.marketplace_store_locations_unavailable'), 422); } // get origins @@ -291,7 +291,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) })->first(); if (!$integratedVendor) { - return response()->error('Integrated vendor not found!'); + return response()->error(__('storefront::checkout.integrated_vendor_not_found')); } try { @@ -355,7 +355,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) return new ServiceQuoteResource($serviceQuote); } - return response()->error('No service rates available!'); + return response()->error(__('storefront::checkout.service_rates_unavailable')); } foreach ($serviceRates as $serviceRate) { @@ -461,7 +461,7 @@ protected function getNetworkDistanceMatrix($origins, Place $destination): objec $next = $remaining->sortBy(fn ($place) => $straightLine($current, $place))->first(); $leg = $this->getDrivingMatrix($current, $next); $distance += (float) $leg->distance; - $time += (float) $leg->time; + $time += (float) $leg->time; $current = $next; $remaining = $remaining->reject(fn ($place) => $place === $next)->values(); } @@ -527,7 +527,7 @@ protected function unavailableCartItems(?Cart $cart, string|array|null $origin = } return response()->json([ - 'errors' => ['Some items in your cart are no longer available.'], + 'errors' => [__('storefront::checkout.cart_items_unavailable')], 'code' => 'cart_items_unavailable', 'items' => $unavailable->all(), ], 422); diff --git a/server/src/Http/Controllers/v1/StoreController.php b/server/src/Http/Controllers/v1/StoreController.php index c9738e10..9753f59e 100644 --- a/server/src/Http/Controllers/v1/StoreController.php +++ b/server/src/Http/Controllers/v1/StoreController.php @@ -30,7 +30,7 @@ public function about() $about = Storefront::about('*'); if (!$about) { - return response()->error('Unable to find store!'); + return response()->error(__('storefront::api.stores.not_found')); } if ($about->is_store) { @@ -48,7 +48,7 @@ public function about() public function lookup(?string $id) { if (!$id) { - return response()->apiError('No ID provided for lookup.'); + return response()->apiError(__('storefront::api.stores.lookup_id_required')); } $store = Store::where('public_id', $id) @@ -72,7 +72,7 @@ public function lookup(?string $id) return new NetworkResource($network); } - return response()->apiError('Unable to find store or network for ID provided.'); + return response()->apiError(__('storefront::api.stores.store_or_network_not_found')); } /** @@ -83,7 +83,7 @@ public function lookup(?string $id) public function locations(Request $request) { if (session('storefront_network') && $request->missing('store')) { - return response()->error('Networks cannot have locations!'); + return response()->error(__('storefront::api.stores.networks_cannot_have_locations')); } if ($request->filled('store')) { @@ -113,7 +113,7 @@ public function locations(Request $request) public function location(string $id, Request $request) { if (session('storefront_network') && $request->missing('store')) { - return response()->error('Networks cannot have locations!'); + return response()->error(__('storefront::api.stores.networks_cannot_have_locations')); } $storeId = $request->input('store', session('storefront_store')); @@ -134,7 +134,7 @@ public function location(string $id, Request $request) // answered 500 with an HTML stack trace for any unknown location, which is what a // client sees for a stale or mistyped id. 404 is the correct answer. if (!$store) { - return response()->error('Unable to find store!', 404); + return response()->error(__('storefront::api.stores.not_found'), 404); } $location = StoreLocation::where([ @@ -145,7 +145,7 @@ public function location(string $id, Request $request) ->first(); if (!$location) { - return response()->error('Store location not found!', 404); + return response()->error(__('storefront::api.stores.location_not_found'), 404); } return new StoreLocationResource($location); diff --git a/server/src/Http/Middleware/SetStorefrontLocale.php b/server/src/Http/Middleware/SetStorefrontLocale.php new file mode 100644 index 00000000..b0c7bc76 --- /dev/null +++ b/server/src/Http/Middleware/SetStorefrontLocale.php @@ -0,0 +1,38 @@ +getLocale(); + app()->setLocale($locale); + + try { + $response = $next($request); + } finally { + app()->setLocale($previous); + } + + CustomerLocale::remember($request, $locale); + + return $response; + } +} diff --git a/server/src/Http/Middleware/SetStorefrontSession.php b/server/src/Http/Middleware/SetStorefrontSession.php index 9cac2455..4c54bad4 100644 --- a/server/src/Http/Middleware/SetStorefrontSession.php +++ b/server/src/Http/Middleware/SetStorefrontSession.php @@ -19,7 +19,7 @@ public function handle(Request $request, \Closure $next) $key = $request->bearerToken(); if (!$key) { - return response()->error('Oops! No Storefront key found with this request', 401); + return response()->error(__('storefront::api.session.key_missing'), 401); } if ($this->isValidKey($key)) { @@ -30,7 +30,7 @@ public function handle(Request $request, \Closure $next) return $next($request); } - return response()->error('Oops! The Storefront key provided was not valid', 401); + return response()->error(__('storefront::api.session.key_invalid'), 401); } /** diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php index fce30a22..d6ab0551 100644 --- a/server/src/Models/Cart.php +++ b/server/src/Models/Cart.php @@ -323,7 +323,7 @@ public function add($product, $quantity = 1, $variants = [], $addons = [], $stor return $this->add($product, $quantity, $variants, $addons, $storeLocationId, $scheduledAt, $createdAt, $catalogId); } - throw new \Exception('Invalid product provided to cart!'); + throw new \Exception(__('storefront::checkout.invalid_cart_product')); } /** @@ -342,7 +342,7 @@ public function addItem(Product $product, $quantity = 1, $variants = [], $addons // When the item comes from a catalog, that catalog's price and availability apply. $override = static::catalogOverride($product, $catalogId); if ($override && $override['is_available'] === false) { - throw new \Exception('This product is not available in the selected catalog.'); + throw new \Exception(__('storefront::checkout.product_unavailable_in_catalog')); } if ($storeLocationId && !Str::startsWith($storeLocationId, 'food_truck_')) { @@ -353,7 +353,7 @@ public function addItem(Product $product, $quantity = 1, $variants = [], $addons ->exists(); if (!$locationBelongsToStore) { - throw new \Exception('The selected store location is not available for this product.'); + throw new \Exception(__('storefront::checkout.store_location_unavailable_for_product')); } } @@ -435,7 +435,7 @@ public function updateItem($cartItem, $quantity = 1, $variants = [], $addons = [ return $this->updateItem($cartItem, $quantity, $variants, $addons, $scheduledAt); } - throw new \Exception('Invalid cart item provided to cart!'); + throw new \Exception(__('storefront::checkout.invalid_cart_item')); } /** @@ -538,7 +538,7 @@ public function remove($cartItem) return $this->remove($cartItem); } - throw new \Exception('Invalid cart item provided to cart!'); + throw new \Exception(__('storefront::checkout.invalid_cart_item')); } /** diff --git a/server/src/Notifications/StorefrontOrderAccepted.php b/server/src/Notifications/StorefrontOrderAccepted.php index 487311c5..be4276b2 100644 --- a/server/src/Notifications/StorefrontOrderAccepted.php +++ b/server/src/Notifications/StorefrontOrderAccepted.php @@ -15,8 +15,8 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' has been accepted.'; - $this->body = 'Your order was accepted.'; + $this->subject = $this->trans('order_accepted.subject', ['store' => $this->storefront->name]); + $this->body = $this->trans('order_accepted.body'); $this->status = 'order_accepted'; } } diff --git a/server/src/Notifications/StorefrontOrderActivity.php b/server/src/Notifications/StorefrontOrderActivity.php index 2b840c31..54ce3a4d 100644 --- a/server/src/Notifications/StorefrontOrderActivity.php +++ b/server/src/Notifications/StorefrontOrderActivity.php @@ -16,7 +16,7 @@ public function __construct(Order $order, string $label, ?string $details = null parent::__construct($order); $this->subject = $label; - $this->body = $details ?: 'Your order from ' . $this->storefront->name . ' has been updated: ' . $label . '.'; + $this->body = $details ?: $this->trans('order_activity.body', ['store' => $this->storefront->name, 'activity' => $label]); $this->status = 'order_activity'; } } diff --git a/server/src/Notifications/StorefrontOrderBalancePaid.php b/server/src/Notifications/StorefrontOrderBalancePaid.php index 24e2ad45..b725c674 100644 --- a/server/src/Notifications/StorefrontOrderBalancePaid.php +++ b/server/src/Notifications/StorefrontOrderBalancePaid.php @@ -19,9 +19,10 @@ public function __construct(Order $order, int $amount) $currency = $order->getMeta('currency'); $left = (int) $order->getMeta('balance_due', 0); $this->amount = $amount; - $this->subject = 'Payment received for your ' . ($order->isMeta('is_booking') ? 'booking' : 'order') . ' from ' . $this->storefront->name; - $this->body = 'We received your payment of ' . Utils::moneyFormat($amount, $currency) . '. ' - . ($left > 0 ? 'There is ' . Utils::moneyFormat($left, $currency) . ' left to pay.' : 'Your ' . ($order->isMeta('is_booking') ? 'booking' : 'order') . ' is fully paid.'); + $kind = $order->isMeta('is_booking') ? 'booking' : 'order'; + $this->subject = $this->trans('order_balance_paid.subject_' . $kind, ['store' => $this->storefront->name]); + $this->body = $this->trans('order_balance_paid.received', ['amount' => Utils::moneyFormat($amount, $currency)]) . ' ' + . ($left > 0 ? $this->trans('order_balance_paid.left_to_pay', ['amount' => Utils::moneyFormat($left, $currency)]) : $this->trans('order_balance_paid.fully_paid_' . $kind)); $this->status = 'order_balance_paid'; } } diff --git a/server/src/Notifications/StorefrontOrderCanceled.php b/server/src/Notifications/StorefrontOrderCanceled.php index cb002348..94469edb 100644 --- a/server/src/Notifications/StorefrontOrderCanceled.php +++ b/server/src/Notifications/StorefrontOrderCanceled.php @@ -15,32 +15,34 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' was canceled'; - $this->body = 'Your order from ' . $this->storefront->name . ' has been canceled' . static::reasonSentence($order) . ' ' . static::moneySentence($order); + $this->subject = $this->trans('order_canceled.subject', ['store' => $this->storefront->name]); + $this->body = $this->reasonSentence($order) . ' ' . $this->moneySentence($order); $this->status = 'order_canceled'; } - protected static function reasonSentence(Order $order): string + protected function reasonSentence(Order $order): string { $reason = trim((string) $order->getMeta('cancel_reason')); - return $reason !== '' ? ': ' . rtrim($reason, '.') . '.' : '.'; + return $reason !== '' + ? $this->trans('order_canceled.body_reason', ['store' => $this->storefront->name, 'reason' => rtrim($reason, '.')]) + : $this->trans('order_canceled.body', ['store' => $this->storefront->name]); } /** * What happens to the customer's money, from the refunds recorded when it was canceled. */ - protected static function moneySentence(Order $order): string + protected function moneySentence(Order $order): string { $payment = \Fleetbase\Storefront\Support\OrderPayments::summary($order); if ($payment['store_credit_code'] && $payment['amount_credited'] > 0) { - return 'Your payment was returned as store credit; use code ' . $payment['store_credit_code'] . ' on your next order.'; + return $this->trans('order_canceled.store_credit', ['code' => $payment['store_credit_code']]); } if ($payment['amount_refunded'] > 0) { - return 'Your payment has been refunded and should reach you within a few days.'; + return $this->trans('order_canceled.refunded'); } - return $payment['amount_paid'] > 0 ? 'If you were charged, you will be refunded.' : ''; + return $payment['amount_paid'] > 0 ? $this->trans('order_canceled.will_refund') : ''; } /** @@ -50,6 +52,6 @@ protected static function moneySentence(Order $order): string */ public function toMail($notifiable) { - return parent::toMail($notifiable)->line('No further action is necessary.'); + return parent::toMail($notifiable)->line($this->trans('order_canceled.no_action')); } } diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php index a4d37073..68751190 100644 --- a/server/src/Notifications/StorefrontOrderChatMessage.php +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -9,12 +9,13 @@ use Fleetbase\Storefront\Push\PushCredentialResolver; use Fleetbase\Storefront\Push\PushMessage; use Fleetbase\Storefront\Push\StorefrontPushChannel; +use Fleetbase\Storefront\Support\CustomerLocale; use Fleetbase\Storefront\Support\CustomerNotificationPresenter; use Illuminate\Bus\Queueable; -use Illuminate\Queue\SerializesModels; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Notification; +use Illuminate\Queue\SerializesModels; /** * Tells a customer their driver sent a message about an order. @@ -29,6 +30,7 @@ class StorefrontOrderChatMessage extends Notification implements SendsPushNotifi public function __construct(public ChatMessage $message, public Order $order) { + $this->locale = CustomerLocale::for($order->customer); } public function via($notifiable): array @@ -38,16 +40,16 @@ public function via($notifiable): array public function title(): string { - $name = data_get($this->message, 'sender.user.name') ?: 'Your driver'; + $name = data_get($this->message, 'sender.user.name') ?: __('storefront::notifications.order_chat_message.default_name'); - return $name . ' sent a message'; + return __('storefront::notifications.order_chat_message.title', ['name' => $name]); } public function body(): string { $content = trim((string) $this->message->content); - return $content !== '' ? $content : 'Sent a photo'; + return $content !== '' ? $content : __('storefront::notifications.order_chat_message.photo'); } public function toPush($notifiable): ?PushMessage diff --git a/server/src/Notifications/StorefrontOrderCompleted.php b/server/src/Notifications/StorefrontOrderCompleted.php index 12a7a86e..2a46dd8b 100644 --- a/server/src/Notifications/StorefrontOrderCompleted.php +++ b/server/src/Notifications/StorefrontOrderCompleted.php @@ -15,8 +15,8 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' has been delivered'; - $this->body = 'Your order from ' . $this->storefront->name . ' has been delivered, enjoy!'; + $this->subject = $this->trans('order_completed.subject', ['store' => $this->storefront->name]); + $this->body = $this->trans('order_completed.body', ['store' => $this->storefront->name]); $this->status = 'order_completed'; } } diff --git a/server/src/Notifications/StorefrontOrderDriverAssigned.php b/server/src/Notifications/StorefrontOrderDriverAssigned.php index 469e2ff4..bf7a4875 100644 --- a/server/src/Notifications/StorefrontOrderDriverAssigned.php +++ b/server/src/Notifications/StorefrontOrderDriverAssigned.php @@ -24,11 +24,11 @@ public function __construct(Order $order) $this->driver = $order->driverAssigned; if (Storefront::isBookingOrder($order)) { - $this->subject = 'Your provider is ' . $this->driver->name; - $this->body = $this->driver->name . ' will be your provider for your booking with ' . $this->storefront->name . '.'; + $this->subject = $this->trans('order_driver_assigned.subject_booking', ['driver' => $this->driver->name]); + $this->body = $this->trans('order_driver_assigned.body_booking', ['driver' => $this->driver->name, 'store' => $this->storefront->name]); } else { - $this->subject = 'Your driver is ' . $this->driver->name; - $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + $this->subject = $this->trans('order_driver_assigned.subject', ['driver' => $this->driver->name]); + $this->body = $this->trans('order_driver_assigned.body', ['store' => $this->storefront->name]); } $this->status = 'order_driver_assigned'; } diff --git a/server/src/Notifications/StorefrontOrderEnroute.php b/server/src/Notifications/StorefrontOrderEnroute.php index 23893981..c679baf0 100644 --- a/server/src/Notifications/StorefrontOrderEnroute.php +++ b/server/src/Notifications/StorefrontOrderEnroute.php @@ -15,8 +15,8 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' is on the way!'; - $this->body = 'Your order from ' . $this->storefront->name . ' has been picked up, we will update you when your order is nearby'; + $this->subject = $this->trans('order_enroute.subject', ['store' => $this->storefront->name]); + $this->body = $this->trans('order_enroute.body', ['store' => $this->storefront->name]); $this->status = 'order_enroute'; } } diff --git a/server/src/Notifications/StorefrontOrderItemUnavailable.php b/server/src/Notifications/StorefrontOrderItemUnavailable.php index 9bf17396..aeef0efd 100644 --- a/server/src/Notifications/StorefrontOrderItemUnavailable.php +++ b/server/src/Notifications/StorefrontOrderItemUnavailable.php @@ -16,9 +16,9 @@ public function __construct(Order $order, string $item) { parent::__construct($order); - $this->item = trim($item) !== '' ? trim($item) : 'An item'; - $this->subject = $this->item . ' is unavailable'; - $this->body = $this->item . ' from your ' . ($order->isMeta('is_booking') ? 'booking' : 'order') . ' is unavailable. ' . $this->storefront->name . ' will replace or remove it and adjust your total if it changes.'; + $this->item = trim($item) !== '' ? trim($item) : $this->trans('order_item_unavailable.default_item'); + $this->subject = $this->trans('order_item_unavailable.subject', ['item' => $this->item]); + $this->body = $this->trans('order_item_unavailable.' . ($order->isMeta('is_booking') ? 'body_booking' : 'body'), ['item' => $this->item, 'store' => $this->storefront->name]); $this->status = 'order_item_unavailable'; } } diff --git a/server/src/Notifications/StorefrontOrderNearby.php b/server/src/Notifications/StorefrontOrderNearby.php index 8a56e801..cc198b1c 100644 --- a/server/src/Notifications/StorefrontOrderNearby.php +++ b/server/src/Notifications/StorefrontOrderNearby.php @@ -3,7 +3,7 @@ namespace Fleetbase\Storefront\Notifications; use Fleetbase\FleetOps\Models\Order; -use Fleetbase\Support\Utils; +use Illuminate\Support\Carbon; class StorefrontOrderNearby extends StorefrontOrderNotification { @@ -28,8 +28,11 @@ public function __construct(Order $order, int $distance = 0, int $time = 0) $this->distance = $distance; $this->time = $time; - $this->subject = 'Your order is nearby!'; - $this->body = 'Your order from ' . $this->storefront->name . ' is reaching in ' . Utils::formatSeconds($time); + $this->subject = $this->trans('order_nearby.subject'); + $this->body = $this->trans('order_nearby.body', [ + 'store' => $this->storefront->name, + 'time' => Carbon::now()->locale($this->locale ?? app()->getLocale())->addSeconds($time)->longAbsoluteDiffForHumans(), + ]); $this->status = 'order_nearby'; } } diff --git a/server/src/Notifications/StorefrontOrderNotification.php b/server/src/Notifications/StorefrontOrderNotification.php index d254e7ca..0011440f 100644 --- a/server/src/Notifications/StorefrontOrderNotification.php +++ b/server/src/Notifications/StorefrontOrderNotification.php @@ -10,15 +10,16 @@ use Fleetbase\Storefront\Push\PushCredentialResolver; use Fleetbase\Storefront\Push\PushMessage; use Fleetbase\Storefront\Push\StorefrontPushChannel; +use Fleetbase\Storefront\Support\CustomerLocale; use Fleetbase\Storefront\Support\CustomerNotificationPresenter; use Fleetbase\Storefront\Support\NotificationPreferences; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Bus\Queueable; -use Illuminate\Queue\SerializesModels; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Queue\SerializesModels; /** * Base class for order lifecycle notifications sent to storefront customers. @@ -65,6 +66,9 @@ abstract class StorefrontOrderNotification extends Notification implements Sends /** * Create a new notification instance. + * + * The text is written in the customer's language. Laravel also sends every channel in + * that locale, including from the queue. */ public function __construct(Order $order) { @@ -72,6 +76,15 @@ public function __construct(Order $order) $this->storefront = Storefront::findAbout($order->getMeta('storefront_id')); $this->sentAt = now()->toDateTimeString(); $this->notificationId = uniqid('notification_'); + $this->locale = CustomerLocale::for($order->customer); + } + + /** + * Translates a line of this notification into the customer's language. + */ + protected function trans(string $key, array $replace = []): string + { + return __('storefront::notifications.' . $key, $replace, $this->locale); } /** diff --git a/server/src/Notifications/StorefrontOrderPreparing.php b/server/src/Notifications/StorefrontOrderPreparing.php index 1b298aa5..afba3a9c 100644 --- a/server/src/Notifications/StorefrontOrderPreparing.php +++ b/server/src/Notifications/StorefrontOrderPreparing.php @@ -15,8 +15,8 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' is being prepared!'; - $this->body = 'Your order is getting started.'; + $this->subject = $this->trans('order_preparing.subject', ['store' => $this->storefront->name]); + $this->body = $this->trans('order_preparing.body'); $this->status = 'order_preparing'; } } diff --git a/server/src/Notifications/StorefrontOrderReadyForPickup.php b/server/src/Notifications/StorefrontOrderReadyForPickup.php index 7cd61c1c..38d39b12 100644 --- a/server/src/Notifications/StorefrontOrderReadyForPickup.php +++ b/server/src/Notifications/StorefrontOrderReadyForPickup.php @@ -15,8 +15,8 @@ public function __construct(Order $order) { parent::__construct($order); - $this->subject = 'Your order from ' . $this->storefront->name . ' is ready for pickup!'; - $this->body = 'You can proceed to pickup your order.'; + $this->subject = $this->trans('order_ready.subject', ['store' => $this->storefront->name]); + $this->body = $this->trans('order_ready.body'); $this->status = 'order_ready'; } } diff --git a/server/src/Notifications/StorefrontOrderUpdated.php b/server/src/Notifications/StorefrontOrderUpdated.php index df9413af..5a4fbddb 100644 --- a/server/src/Notifications/StorefrontOrderUpdated.php +++ b/server/src/Notifications/StorefrontOrderUpdated.php @@ -23,22 +23,32 @@ public function __construct(Order $order, string $summary = '', array $changes = parent::__construct($order); $isBooking = (bool) $order->isMeta('is_booking'); - $moved = $isBooking && $changes === ['schedule'] ? static::bookingTime($order, $this->storefront instanceof Store ? $this->storefront : null) : null; + $moved = $isBooking && $changes === ['schedule'] ? $this->bookingTime($order, $this->storefront instanceof Store ? $this->storefront : null) : null; + $store = ['store' => $this->storefront->name]; if ($moved) { - $this->subject = 'Your booking with ' . $this->storefront->name . ' was moved'; - $this->body = trim($moved . ' ' . static::moneySentence($order)); + $this->subject = $this->trans('order_updated.subject_moved', $store); + $this->body = trim($moved . ' ' . $this->moneySentence($order)); } else { - $this->subject = 'Your ' . ($isBooking ? 'booking with ' : 'order from ') . $this->storefront->name . ' was updated'; - $this->body = trim(($summary !== '' ? rtrim($summary, '.') . '.' : 'Your order from ' . $this->storefront->name . ' was updated.') . ' ' . static::moneySentence($order)); + $this->subject = $this->trans($isBooking ? 'order_updated.subject_booking' : 'order_updated.subject', $store); + $this->body = trim(($summary !== '' && $this->inEnglish() ? rtrim($summary, '.') . '.' : $this->trans('order_updated.body', $store)) . ' ' . $this->moneySentence($order)); } $this->status = 'order_updated'; } + /** + * The edit summary is written in English for the console and the driver as well, so a + * customer in another language gets the general line instead of a mixed-language message. + */ + protected function inEnglish(): bool + { + return ($this->locale ?? app()->getLocale()) === 'en'; + } + /** * "Deep Clean is now Mon 19 Oct, 11:00 to 12:00.", in the store's time. */ - protected static function bookingTime(Order $order, ?Store $store): ?string + protected function bookingTime(Order $order, ?Store $store): ?string { $start = data_get($order->meta, 'booking.start') ?? data_get($order->meta, 'booking_at'); if (!$start) { @@ -46,24 +56,29 @@ protected static function bookingTime(Order $order, ?Store $store): ?string } $timezone = BookingSlots::timezone($store); - $from = Carbon::parse($start)->setTimezone($timezone); + $locale = $this->locale ?? app()->getLocale(); + $from = Carbon::parse($start)->setTimezone($timezone)->locale($locale)->translatedFormat('D j M, H:i'); $end = data_get($order->meta, 'booking.end'); - $when = $from->format('D j M, H:i') . ($end ? ' to ' . Carbon::parse($end)->setTimezone($timezone)->format('H:i') : ''); + $when = $end ? $this->trans('order_updated.booking_time_range', ['start' => $from, 'end' => Carbon::parse($end)->setTimezone($timezone)->format('H:i')]) : $from; $service = collect(data_get($order, 'payload.entities', []))->first(fn ($entity) => data_get($entity, 'meta.scheduled_at')); $name = data_get($service, 'name'); - return ($name ? $name . ' is now ' : 'Your booking is now ') . $when . '.'; + return $name + ? $this->trans('order_updated.booking_time_named', ['service' => $name, 'when' => $when]) + : $this->trans('order_updated.booking_time', ['when' => $when]); } - protected static function moneySentence(Order $order): string + protected function moneySentence(Order $order): string { $payment = OrderPayments::summary($order); $last = collect($payment['adjustments'])->last(); return match ($last['type'] ?? null) { - 'refund' => 'The difference has been refunded.', - 'store_credit' => 'The difference was added as store credit: use code ' . ($last['reference'] ?? '') . ' on your next order.', - default => $payment['state'] === OrderPayments::STATE_BALANCE_DUE ? 'There is a balance to pay' . ($payment['payment_link'] ? ': ' . $payment['payment_link'] : '.') : '', + 'refund' => $this->trans('order_updated.refunded'), + 'store_credit' => $this->trans('order_updated.store_credit', ['code' => $last['reference'] ?? '']), + default => $payment['state'] === OrderPayments::STATE_BALANCE_DUE + ? ($payment['payment_link'] ? $this->trans('order_updated.balance_due_link', ['link' => $payment['payment_link']]) : $this->trans('order_updated.balance_due')) + : '', }; } } diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index a0ae8b6c..a934ad3f 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -52,6 +52,7 @@ class StorefrontServiceProvider extends CoreServiceProvider */ public $middleware = [ 'storefront.api' => [ + \Fleetbase\Storefront\Http\Middleware\SetStorefrontLocale::class, \Fleetbase\Storefront\Http\Middleware\ThrottleRequests::class, \Illuminate\Session\Middleware\StartSession::class, \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, @@ -126,6 +127,7 @@ public function boot() $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); + $this->loadTranslationsFrom(__DIR__ . '/../../resources/lang', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); diff --git a/server/src/Rules/CartExists.php b/server/src/Rules/CartExists.php index caad0459..623c5d95 100644 --- a/server/src/Rules/CartExists.php +++ b/server/src/Rules/CartExists.php @@ -29,6 +29,6 @@ public function passes($attribute, $value) */ public function message() { - return 'Cart session does not exists.'; + return __('storefront::api.validation.cart_not_found'); } } diff --git a/server/src/Rules/CustomerExists.php b/server/src/Rules/CustomerExists.php index 0a85e298..a98364af 100644 --- a/server/src/Rules/CustomerExists.php +++ b/server/src/Rules/CustomerExists.php @@ -29,6 +29,6 @@ public function passes($attribute, $value) */ public function message() { - return 'No customer found.'; + return __('storefront::api.validation.customer_not_found'); } } diff --git a/server/src/Rules/GatewayExists.php b/server/src/Rules/GatewayExists.php index 01bf71b8..d5332a47 100644 --- a/server/src/Rules/GatewayExists.php +++ b/server/src/Rules/GatewayExists.php @@ -30,6 +30,6 @@ public function passes($attribute, $value) */ public function message() { - return 'No gateway by code provided exists.'; + return __('storefront::api.validation.gateway_not_found'); } } diff --git a/server/src/Rules/IsValidLocation.php b/server/src/Rules/IsValidLocation.php index a89dd64a..08ed9053 100644 --- a/server/src/Rules/IsValidLocation.php +++ b/server/src/Rules/IsValidLocation.php @@ -61,6 +61,6 @@ public function passes($attribute, $value) */ public function message() { - return 'Invalid :attribute.'; + return __('storefront::api.validation.invalid_location'); } } diff --git a/server/src/Support/CustomerLocale.php b/server/src/Support/CustomerLocale.php new file mode 100644 index 00000000..87806b68 --- /dev/null +++ b/server/src/Support/CustomerLocale.php @@ -0,0 +1,93 @@ +` exists. + */ +class CustomerLocale +{ + public const META_KEY = 'locale'; + + /** + * The supported language codes, English first so it is the fallback. + */ + protected static ?array $available = null; + + public static function available(): array + { + if (static::$available === null) { + $codes = array_map('basename', glob(static::path() . '/*', GLOB_ONLYDIR) ?: []); + usort($codes, fn ($a, $b) => ($b === 'en') <=> ($a === 'en') ?: strcmp($a, $b)); + + static::$available = $codes; + } + + return static::$available; + } + + public static function path(): string + { + return dirname(__DIR__, 2) . '/resources/lang'; + } + + public static function supports(?string $locale): bool + { + return is_string($locale) && in_array($locale, static::available(), true); + } + + /** + * The best supported language for the request's `Accept-Language`, or null without the header. + * Regional codes match their language (`mn-MN` is `mn`), and an unsupported language is English. + */ + public static function fromRequest(Request $request): ?string + { + if (!$request->headers->has('Accept-Language')) { + return null; + } + + return $request->getPreferredLanguage(static::available()); + } + + /** + * The language a customer last used, or null when they have none stored. + */ + public static function for($notifiable): ?string + { + if (!is_object($notifiable) || !method_exists($notifiable, 'getMeta')) { + return null; + } + + $locale = $notifiable->getMeta(static::META_KEY); + + return static::supports($locale) ? $locale : null; + } + + /** + * Stores the language for the customer signed in to this request, when it changed. + * Written straight to the table so it fires no model events. + */ + public static function remember(Request $request, string $locale): void + { + $uuid = session('customer'); + + if (!$uuid || !$request->header('Customer-Token')) { + return; + } + + $contact = Contact::select(['uuid', 'meta'])->where('uuid', $uuid)->first(); + + if ($contact && $contact->getMeta(static::META_KEY) !== $locale) { + $contact->updateMetaProperties([static::META_KEY => $locale]); + } + } +} diff --git a/server/src/Support/ReviewEligibility.php b/server/src/Support/ReviewEligibility.php index 7c7fd966..f2c0e270 100644 --- a/server/src/Support/ReviewEligibility.php +++ b/server/src/Support/ReviewEligibility.php @@ -32,13 +32,15 @@ class ReviewEligibility public const ORDER_LOOKBACK = 50; /** + * The translation key of the message for each reason, in the customer's language. + * * @var array */ public const MESSAGES = [ - self::SIGN_IN_REQUIRED => 'Sign in to write a review.', - self::UNSUPPORTED_SUBJECT => 'Only stores and products can be reviewed.', - self::NO_COMPLETED_ORDER => 'You can review this after an order with it is completed.', - self::ALREADY_REVIEWED => 'You have already reviewed this for your completed orders.', + self::SIGN_IN_REQUIRED => 'storefront::api.reviews.sign_in_required', + self::UNSUPPORTED_SUBJECT => 'storefront::api.reviews.unsupported_subject', + self::NO_COMPLETED_ORDER => 'storefront::api.reviews.no_completed_order', + self::ALREADY_REVIEWED => 'storefront::api.reviews.already_reviewed', ]; public function __construct( @@ -117,7 +119,7 @@ public static function completedOrdersFor(Contact $customer, mixed $subject): ?B public function message(): ?string { - return $this->reason ? static::MESSAGES[$this->reason] : null; + return $this->reason ? __(static::MESSAGES[$this->reason]) : null; } /** diff --git a/server/tests/Unit/Http/Middleware/SetStorefrontLocaleTest.php b/server/tests/Unit/Http/Middleware/SetStorefrontLocaleTest.php new file mode 100644 index 00000000..d0c6bef8 --- /dev/null +++ b/server/tests/Unit/Http/Middleware/SetStorefrontLocaleTest.php @@ -0,0 +1,244 @@ + 'en']): void +{ + $schema = Capsule::schema('mysql'); + $schema->dropIfExists('contacts'); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('type')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Capsule::connection('mysql')->table('contacts')->insert([ + 'uuid' => 'locale_contact_uuid', + 'public_id' => 'contact_locale', + 'name' => 'Bat', + 'type' => 'customer', + 'meta' => json_encode($meta), + ]); + + $schema->dropIfExists('stores'); + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id'); + $table->string('company_uuid')->nullable(); + $table->string('name'); + $table->string('currency')->nullable(); + $table->string('timezone')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Capsule::connection('mysql')->table('stores')->insert([ + 'uuid' => 'locale_store_uuid', + 'public_id' => 'store_locale', + 'name' => 'Nomin', + 'currency' => 'MNT', + 'timezone' => 'Asia/Ulaanbaatar', + 'options' => '{}', + ]); +} + +function localeTestStoredLocale(): ?string +{ + return data_get(json_decode((string) Capsule::connection('mysql')->table('contacts')->where('uuid', 'locale_contact_uuid')->value('meta'), true), 'locale'); +} + +function localeTestOrder(?string $locale, array $meta = []): Order +{ + $customer = new Contact(); + $customer->forceFill(['uuid' => 'locale_contact_uuid', 'meta' => $locale ? ['locale' => $locale] : []]); + + $order = new Order(); + $order->forceFill([ + 'uuid' => 'locale_order_uuid', + 'public_id' => 'order_locale', + 'meta' => ['storefront_id' => 'store_locale', 'currency' => 'MNT', ...$meta], + ]); + $order->setRelation('customer', $customer); + + return $order; +} + +function localeTestRequest(?string $language, bool $signedIn = false): Request +{ + $server = $language === null ? [] : ['HTTP_ACCEPT_LANGUAGE' => $language]; + if ($signedIn) { + $server['HTTP_CUSTOMER_TOKEN'] = 'customer_token'; + } + + $request = Request::create('/storefront/v1/checkouts/before', 'GET', [], [], [], $server); + if ($language === null) { + // Request::create() adds an English Accept-Language of its own. + $request->headers->remove('Accept-Language'); + } + + return $request; +} + +afterEach(function () { + session(['customer' => null]); + app()->setLocale('en'); +}); + +test('storefront supports the languages it ships, english first', function () { + expect(CustomerLocale::available())->toBe(['en', 'mn', 'uk']) + ->and(CustomerLocale::supports('mn'))->toBeTrue() + ->and(CustomerLocale::supports('fr'))->toBeFalse() + ->and(CustomerLocale::supports(null))->toBeFalse(); +}); + +test('every language has the same messages and placeholders as english', function () { + $flatten = function (array $lines, string $prefix = '') use (&$flatten): array { + $flat = []; + foreach ($lines as $key => $value) { + $flat = is_array($value) ? [...$flat, ...$flatten($value, $prefix . $key . '.')] : [...$flat, $prefix . $key => $value]; + } + + return $flat; + }; + $placeholders = function (string $line): array { + preg_match_all('/:([a-z_]+)/', $line, $matches); + $names = array_unique($matches[1]); + sort($names); + + return $names; + }; + + foreach (glob(CustomerLocale::path() . '/en/*.php') as $file) { + $english = $flatten(require $file); + + foreach (CustomerLocale::available() as $locale) { + $translated = $flatten(require CustomerLocale::path() . '/' . $locale . '/' . basename($file)); + + expect(array_keys($translated))->toEqualCanonicalizing(array_keys($english), $locale . '/' . basename($file)); + foreach ($english as $key => $line) { + expect($placeholders($translated[$key]))->toBe($placeholders($line), $locale . '/' . basename($file) . ' ' . $key); + } + } + } +}); + +test('the request language picks the closest supported language', function () { + expect(CustomerLocale::fromRequest(localeTestRequest(null)))->toBeNull() + ->and(CustomerLocale::fromRequest(localeTestRequest('mn')))->toBe('mn') + ->and(CustomerLocale::fromRequest(localeTestRequest('mn-MN,en;q=0.5')))->toBe('mn') + ->and(CustomerLocale::fromRequest(localeTestRequest('uk-UA')))->toBe('uk') + ->and(CustomerLocale::fromRequest(localeTestRequest('en-US')))->toBe('en') + ->and(CustomerLocale::fromRequest(localeTestRequest('fr')))->toBe('en'); +}); + +test('a customer language is read from their meta when it is supported', function () { + $customer = new Contact(); + + expect(CustomerLocale::for(null))->toBeNull() + ->and(CustomerLocale::for(new stdClass()))->toBeNull() + ->and(CustomerLocale::for($customer->forceFill(['meta' => []])))->toBeNull() + ->and(CustomerLocale::for($customer->forceFill(['meta' => ['locale' => 'fr']])))->toBeNull() + ->and(CustomerLocale::for($customer->forceFill(['meta' => ['locale' => 'mn']])))->toBe('mn'); +}); + +test('the middleware answers in the request language and restores the previous one', function () { + $middleware = new SetStorefrontLocale(); + $seen = null; + $next = function () use (&$seen) { + $seen = [app()->getLocale(), __('storefront::notifications.order_nearby.subject')]; + + return response()->json(['ok' => true]); + }; + + $middleware->handle(localeTestRequest(null), $next); + expect($seen)->toBe(['en', 'Your order is nearby!']); + + $response = $middleware->handle(localeTestRequest('mn-MN'), $next); + expect($seen)->toBe(['mn', 'Таны захиалга ойртож байна!']) + ->and($response->getData(true))->toBe(['ok' => true]) + ->and(app()->getLocale())->toBe('en'); +}); + +test('the middleware remembers the language of a signed-in customer only when it changed', function () { + localeTestSchema(); + $middleware = new SetStorefrontLocale(); + $next = fn () => response()->json(['ok' => true]); + + // A guest, or a session without a customer token, stores nothing. + $middleware->handle(localeTestRequest('mn'), $next); + session(['customer' => 'locale_contact_uuid']); + $middleware->handle(localeTestRequest('mn'), $next); + expect(localeTestStoredLocale())->toBe('en'); + + $middleware->handle(localeTestRequest('mn', true), $next); + expect(localeTestStoredLocale())->toBe('mn'); + + $middleware->handle(localeTestRequest('mn', true), $next); + expect(localeTestStoredLocale())->toBe('mn'); + + session(['customer' => 'missing_contact_uuid']); + $middleware->handle(localeTestRequest('uk', true), $next); + expect(localeTestStoredLocale())->toBe('mn'); +}); + +test('order notifications are written in the customer language', function () { + localeTestSchema(); + + $english = new StorefrontOrderAccepted(localeTestOrder(null)); + $mongolian = new StorefrontOrderAccepted(localeTestOrder('mn')); + $ukrainian = new StorefrontOrderAccepted(localeTestOrder('uk')); + + expect($english->locale)->toBeNull() + ->and($english->subject)->toBe('Your order from Nomin has been accepted.') + ->and($mongolian->locale)->toBe('mn') + ->and($mongolian->subject)->toBe('Nomin таны захиалгыг хүлээн авлаа.') + ->and($mongolian->toPush(null)->body)->toBe('Таны захиалга баталгаажлаа.') + ->and($ukrainian->subject)->toBe('Nomin прийняв ваше замовлення.'); +}); + +test('times in notifications are written in the customer language', function () { + localeTestSchema(); + + $nearby = new StorefrontOrderNearby(localeTestOrder('uk'), 900, 300); + + expect($nearby->body)->toStartWith('Ваше замовлення з Nomin прибуде через ') + ->and($nearby->body)->not->toContain('minute'); +}); + +test('an english edit summary is only shown to customers reading english', function () { + localeTestSchema(); + + $english = new StorefrontOrderUpdated(localeTestOrder('en'), 'Tea 2→1', ['items']); + $mongolian = new StorefrontOrderUpdated(localeTestOrder('mn'), 'Tea 2→1', ['items']); + + expect($english->body)->toBe('Tea 2→1.') + ->and($mongolian->subject)->toBe('Nomin-ийн таны захиалга шинэчлэгдлээ') + ->and($mongolian->body)->toBe('Nomin-ийн таны захиалга шинэчлэгдлээ.'); +}); + +test('a chat message notification is sent in the customer language', function () { + $notification = new StorefrontOrderChatMessage(new ChatMessage(['content' => '']), localeTestOrder('mn')); + + app()->setLocale($notification->locale); + + expect($notification->locale)->toBe('mn') + ->and($notification->title())->toBe('Таны жолооч танд мессеж илгээлээ') + ->and($notification->body())->toBe('Зураг илгээлээ'); +}); From 1fc991dd89f066dc01450f01c19def8cef7d7070 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 12:33:37 +0800 Subject: [PATCH 6/9] fix(i18n): Mongolian not-found messages ask the customer to register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a native speaker's review: customer_not_found, no_customer_with_phone and customer_user_not_found now add "Шинэ хэрэглэгчээр бүртгүүлнэ үү." --- server/resources/lang/mn/customers.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/resources/lang/mn/customers.php b/server/resources/lang/mn/customers.php index 1fa2b2ca..1fc5f3f9 100644 --- a/server/resources/lang/mn/customers.php +++ b/server/resources/lang/mn/customers.php @@ -21,11 +21,11 @@ 'invalid_verification_code' => 'Баталгаажуулах код буруу байна!', 'unable_to_verify_code' => 'Кодыг баталгаажуулж чадсангүй.', 'not_authorized_update_customer' => 'Хэрэглэгчийн мэдээллийг шинэчлэх эрхгүй байна.', - 'customer_not_found' => 'Хэрэглэгч олдсонгүй.', + 'customer_not_found' => 'Хэрэглэгч олдсонгүй. Шинэ хэрэглэгчээр бүртгүүлнэ үү.', 'not_authorized_view_customers' => 'Хэрэглэгчдийг харах эрхгүй байна.', 'not_authorized_view_customer' => 'Хэрэглэгчийг харах эрхгүй байна.', 'password_authentication_failed' => 'Нууц үг буруу тул нэвтэрч чадсангүй.', - 'no_customer_with_phone' => 'Энэ утасны дугаартай хэрэглэгч олдсонгүй.', + 'no_customer_with_phone' => 'Энэ утасны дугаартай хэрэглэгч олдсонгүй. Шинэ хэрэглэгчээр бүртгүүлнэ үү.', 'unable_to_send_verification_code' => 'Баталгаажуулах код илгээж чадсангүй.', 'apple_parameters_missing' => 'Apple-ээр нэвтрэхэд шаардлагатай мэдээлэл дутуу байна.', 'apple_authentication_invalid' => 'Apple ID-ээр нэвтрэх баталгаажуулалт хүчингүй байна.', @@ -33,7 +33,7 @@ 'google_authentication_invalid' => 'Google-ээр нэвтрэх баталгаажуулалт хүчингүй байна.', 'stripe_not_setup' => 'Stripe тохируулагдаагүй байна.', 'storefront_not_found' => 'Дэлгүүр олдсонгүй.', - 'customer_user_not_found' => 'Хэрэглэгчийн бүртгэл олдсонгүй.', + 'customer_user_not_found' => 'Хэрэглэгчийн бүртгэл олдсонгүй. Шинэ хэрэглэгчээр бүртгүүлнэ үү.', 'customer_contact_required' => 'Бүртгэлд хүчинтэй имэйл эсвэл утасны дугаар холбогдсон байх шаардлагатай.', 'account_closure_code_message' => 'Таны :name бүртгэл устгах баталгаажуулах код: :code', 'account_closure_subject' => ':name бүртгэл устгах хүсэлт', From bbe38c04340c53d7b5c662ed6ddcefc4a15449eb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 12:35:43 +0800 Subject: [PATCH 7/9] fix(i18n): keep the Mongolian register hint to phone sign-in customer_not_found (update, find, delete) and customer_user_not_found (account closure) are not sign-in errors, so telling the customer to register there read wrongly. Only no_customer_with_phone (loginWithPhone) keeps the hint. --- server/resources/lang/mn/customers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/resources/lang/mn/customers.php b/server/resources/lang/mn/customers.php index 1fc5f3f9..c9efc772 100644 --- a/server/resources/lang/mn/customers.php +++ b/server/resources/lang/mn/customers.php @@ -21,7 +21,7 @@ 'invalid_verification_code' => 'Баталгаажуулах код буруу байна!', 'unable_to_verify_code' => 'Кодыг баталгаажуулж чадсангүй.', 'not_authorized_update_customer' => 'Хэрэглэгчийн мэдээллийг шинэчлэх эрхгүй байна.', - 'customer_not_found' => 'Хэрэглэгч олдсонгүй. Шинэ хэрэглэгчээр бүртгүүлнэ үү.', + 'customer_not_found' => 'Хэрэглэгч олдсонгүй.', 'not_authorized_view_customers' => 'Хэрэглэгчдийг харах эрхгүй байна.', 'not_authorized_view_customer' => 'Хэрэглэгчийг харах эрхгүй байна.', 'password_authentication_failed' => 'Нууц үг буруу тул нэвтэрч чадсангүй.', @@ -33,7 +33,7 @@ 'google_authentication_invalid' => 'Google-ээр нэвтрэх баталгаажуулалт хүчингүй байна.', 'stripe_not_setup' => 'Stripe тохируулагдаагүй байна.', 'storefront_not_found' => 'Дэлгүүр олдсонгүй.', - 'customer_user_not_found' => 'Хэрэглэгчийн бүртгэл олдсонгүй. Шинэ хэрэглэгчээр бүртгүүлнэ үү.', + 'customer_user_not_found' => 'Хэрэглэгчийн бүртгэл олдсонгүй.', 'customer_contact_required' => 'Бүртгэлд хүчинтэй имэйл эсвэл утасны дугаар холбогдсон байх шаардлагатай.', 'account_closure_code_message' => 'Таны :name бүртгэл устгах баталгаажуулах код: :code', 'account_closure_subject' => ':name бүртгэл устгах хүсэлт', From 201babf9cddc7e52b545fddef95a37acd1613fcf Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 17:03:51 +0800 Subject: [PATCH 8/9] docs(release): v0.4.30 notes cover server localization (#164) --- RELEASE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index ce0252cc..d2839a90 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,4 @@ -> v0.4.30 ~ "App store reviewers can sign in, and Continue with Fleetbase" +> v0.4.30 ~ "App store reviewers can sign in, Continue with Fleetbase, and answers in the customer's language" --- ## Highlights @@ -8,6 +8,7 @@ - **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code. - **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. (#162) - **Continue with Fleetbase.** Apps signed in with core-api's OAuth provider (core-api 1.6.74) can, with an organization administrator's consent, read the organization's stores and networks with their public keys (`storefront:read`, `GET /storefront/oauth/v1/storefronts`), get a dedicated API key for their Storefront app (`storefront:app-key`, `POST /storefront/oauth/v1/app-key`, listed under Developers → API Keys) and turn App Review access on or off (`storefront:review-access`, through core's `/oauth/v1/review-access/storefront-store/{id}`). This is how AppHelm connects a Cloud customer's storefront without pasting keys. (#163) +- **Answers in the customer's language.** The customer app already sends `Accept-Language`, but the server ignored it. Storefront API errors, validation messages, SMS verification and account-closure texts, push notifications and inbox items now come back in the closest shipped language (English, Mongolian or Ukrainian; anything else gets English). A signed-in customer's language is remembered in their contact's `meta.locale`, so queued order notifications use it too. Merchant-authored text, error codes and core-api / Fleet-Ops messages are unchanged, and English text is identical to before. (#164) --- ## Upgrading From be542d39035facfe8bd676feebf3ddeb17a601be Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 17:21:24 +0800 Subject: [PATCH 9/9] test(oauth,app-review): cover the OAuth storefront endpoints and the review guards The OAuth StorefrontController (#163) had no tests. Cover the store and network listing (scoped to the token's organization) and the app key (created once, reused after; expired, test and other-company keys are never returned). Also cover the App Review notice in new-order SMS and mail, and a customer whose account can't be loaded. OAuthScopes no longer checks for core's OAuth provider: storefront requires core-api ^1.6.74, which always has it. --- server/src/Support/OAuthScopes.php | 4 - .../Http/Controllers/AppReviewAccessTest.php | 11 ++ .../OAuthStorefrontControllerTest.php | 132 ++++++++++++++++++ .../NotificationContractsTest.php | 18 +++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 server/tests/Unit/Http/Controllers/OAuthStorefrontControllerTest.php diff --git a/server/src/Support/OAuthScopes.php b/server/src/Support/OAuthScopes.php index af27bbdf..02cacb67 100644 --- a/server/src/Support/OAuthScopes.php +++ b/server/src/Support/OAuthScopes.php @@ -18,10 +18,6 @@ class OAuthScopes public static function register(): void { - if (!class_exists(CoreOAuthScopes::class)) { - return; - } - CoreOAuthScopes::register(static::READ, [ 'description' => "See {organization}'s storefronts and networks, with their public keys", 'organization' => true, diff --git a/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php index 1daff29b..cd7bcca6 100644 --- a/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php +++ b/server/tests/Unit/Http/Controllers/AppReviewAccessTest.php @@ -476,3 +476,14 @@ function appReviewUserCount(): int ->and($resources['network']['actions'])->toContain('manage-app-review') ->and(Fleetbase\Support\Utils::slugify('manageAppReview'))->toBe('manage-app-review'); }); + +test('a customer whose account cannot be loaded is judged by its own phone and email', function () { + createAppReviewSchema(); + Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->drop('users'); + + $reviewer = (new Contact())->forceFill(['phone' => AppReview::PHONE, 'user_uuid' => 'user_missing']); + $customer = (new Contact())->forceFill(['phone' => '+6591234567', 'user_uuid' => 'user_missing']); + + expect(AppReview::isReviewCustomer($reviewer))->toBeTrue() + ->and(AppReview::isReviewCustomer($customer))->toBeFalse(); +}); diff --git a/server/tests/Unit/Http/Controllers/OAuthStorefrontControllerTest.php b/server/tests/Unit/Http/Controllers/OAuthStorefrontControllerTest.php new file mode 100644 index 00000000..3581ae4f --- /dev/null +++ b/server/tests/Unit/Http/Controllers/OAuthStorefrontControllerTest.php @@ -0,0 +1,132 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['stores', 'networks', 'api_credentials'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach ([ + 'uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'key', 'name', 'description', + 'website', 'email', 'phone', 'currency', 'timezone', 'online', 'options', 'slug', + ] as $column) { + $blueprint->text($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->softDeletes(); + }); + } + $schema->create('api_credentials', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'key', 'secret', 'api', 'expires_at', 'last_used_at'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->boolean('test_mode')->default(false); + $blueprint->timestamps(); + $blueprint->softDeletes(); + }); + + $connection->table('stores')->insert([ + ['uuid' => 'store_b_uuid', 'public_id' => 'store_b', 'company_uuid' => 'company_a', 'key' => 'store_b_key', 'name' => 'Bakery', 'currency' => 'USD', 'online' => '1', 'options' => '{"pickup_enabled":true}'], + ['uuid' => 'store_a_uuid', 'public_id' => 'store_a', 'company_uuid' => 'company_a', 'key' => 'store_a_key', 'name' => 'Apothecary', 'currency' => 'USD', 'online' => '0', 'options' => null], + ['uuid' => 'store_x_uuid', 'public_id' => 'store_x', 'company_uuid' => 'company_b', 'key' => 'store_x_key', 'name' => 'Elsewhere', 'currency' => 'USD', 'online' => '1', 'options' => null], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network_a_uuid', 'public_id' => 'network_a', 'company_uuid' => 'company_a', 'key' => 'network_a_key', 'name' => 'Market', 'currency' => 'USD', 'online' => '1', 'options' => null], + ]); + + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + config(['activitylog.enabled' => false]); + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, tap(new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]])))->disable()); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + // What core's ApiCredentialObserver does on create. + ApiCredential::creating(fn ($credential) => $credential->uuid ??= (string) Str::uuid()); + ApiCredential::created(fn ($credential) => $credential->forceFill(['key' => 'flb_live_' . Str::random(8)])->saveQuietly()); +} + +function oauthStorefrontRequest(string $method = 'GET'): Request +{ + $company = (new Company())->forceFill(['uuid' => 'company_a', 'name' => 'Company A']); + $client = (new OAuthClient())->forceFill(['id' => 'client_1', 'name' => 'AppHelm']); + $token = (new OAuthAccessToken())->forceFill(['user_uuid' => 'user_a', 'company_uuid' => 'company_a', 'client_id' => 'client_1']); + $token->setRelation('company', $company)->setRelation('client', $client); + + $request = Request::create('/storefront/oauth/v1/storefronts', $method); + $request->attributes->set(AuthenticateOAuthToken::ATTRIBUTE, $token); + + return $request; +} + +afterEach(function () { + Model::unsetEventDispatcher(); +}); + +test('an app sees only its organization stores and networks with their public keys', function () { + createOAuthStorefrontSchema(); + + $response = (new StorefrontController())->storefronts(oauthStorefrontRequest()); + $body = $response->getData(true)['storefronts']; + + expect($response)->toBeInstanceOf(JsonResponse::class) + ->and($response->headers->get('Cache-Control'))->toContain('no-store') + ->and(array_column($body, 'id'))->toBe(['store_a', 'store_b', 'network_a']) + ->and($body[1])->toMatchArray([ + 'type' => 'store', + 'key' => 'store_b_key', + 'name' => 'Bakery', + 'online' => true, + 'pickup_enabled' => true, + 'review_access_type' => AppReview::STORE_TYPE, + ]) + ->and($body[0])->toMatchArray(['online' => false, 'pickup_enabled' => false]) + ->and($body[2])->toMatchArray(['type' => 'network', 'key' => 'network_a_key', 'review_access_type' => AppReview::NETWORK_TYPE]) + ->and($body[1])->not->toHaveKey('secret'); +}); + +test('an app gets one live API key in its organization and the same key on later calls', function () { + createOAuthStorefrontSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + // A key of the same name in another company, an expired one and a test key are never reused. + $connection->table('api_credentials')->insert([ + ['uuid' => 'other_company', 'company_uuid' => 'company_b', 'name' => 'Storefront app (AppHelm)', 'key' => 'flb_live_other', 'test_mode' => false, 'expires_at' => null], + ['uuid' => 'expired', 'company_uuid' => 'company_a', 'name' => 'Storefront app (AppHelm)', 'key' => 'flb_live_expired', 'test_mode' => false, 'expires_at' => '2000-01-01 00:00:00'], + ['uuid' => 'test_key', 'company_uuid' => 'company_a', 'name' => 'Storefront app (AppHelm)', 'key' => 'flb_test_key', 'test_mode' => true, 'expires_at' => null], + ]); + + $first = (new StorefrontController())->appKey(oauthStorefrontRequest('POST')); + $second = (new StorefrontController())->appKey(oauthStorefrontRequest('POST')); + $key = $first->getData(true)['api_key']; + + expect($first->getStatusCode())->toBe(201) + ->and($first->headers->get('Cache-Control'))->toContain('no-store') + ->and($key)->toMatchArray(['name' => 'Storefront app (AppHelm)', 'created' => true]) + ->and($key['key'])->toStartWith('flb_live_') + ->and($key['key'])->not->toBe('flb_live_other') + ->and($second->getStatusCode())->toBe(200) + ->and($second->getData(true)['api_key'])->toMatchArray(['key' => $key['key'], 'created' => false]) + ->and($connection->table('api_credentials')->where('company_uuid', 'company_a')->where('user_uuid', 'user_a')->count())->toBe(1); +}); diff --git a/server/tests/Unit/Notifications/NotificationContractsTest.php b/server/tests/Unit/Notifications/NotificationContractsTest.php index ad4f57a2..05e95237 100644 --- a/server/tests/Unit/Notifications/NotificationContractsTest.php +++ b/server/tests/Unit/Notifications/NotificationContractsTest.php @@ -293,6 +293,24 @@ function notificationOrder(array $meta = []): Order ->and($mailLines)->toContain('Delivery Tip:'); }); +test('created-order notification tells the store an App Review order is a test', function () { + $order = notificationOrder([ + 'is_pickup' => true, + 'subtotal' => 1000, + 'total' => 1000, + 'currency' => 'USD', + 'app_review' => true, + ]); + $order->setRelation('payload', (object) [ + 'entities' => collect([(object) ['name' => 'Coffee']]), + 'dropoff' => (object) ['address' => '1 Market Street'], + ]); + $notification = notificationWithoutConstructor(StorefrontOrderCreated::class, $order, new Store(['name' => 'Corner Store'])); + + expect($notification->toTwilio(null)->content)->toContain(Fleetbase\Storefront\Support\AppReview::ORDER_NOTICE) + ->and(implode("\n", $notification->toMail(null)->introLines))->toContain(Fleetbase\Storefront\Support\AppReview::ORDER_NOTICE); +}); + test('created-order notification resolves its storefront from order metadata', function () { $schema = Illuminate\Database\Capsule\Manager::schema('mysql'); $schema->dropIfExists('stores');