Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
37baff7
Add Ukrainian translation
ispdomnet Sep 22, 2026
ea48962
fix: rebuild storefront push notifications on a single push channel
roncodes Sep 26, 2026
28fdf2d
test: cover the push transports, message setters and delivery fallbacks
roncodes Sep 26, 2026
467a420
feat: add a customer notifications inbox API
roncodes Sep 26, 2026
1d49ebf
test: cover the inbox lookup, storefront scoping fallbacks and broadc…
roncodes Sep 26, 2026
ff2c917
feat: add a promotions engine with codes, stacking and checkout integ…
roncodes Sep 26, 2026
566bc87
Merge branch 'feat/customer-notifications-inbox' into feat/promotion-…
roncodes Sep 26, 2026
db015df
feat: add customer segments and campaigns
roncodes Sep 26, 2026
c7a10fb
feat: add console screens for promotions, promo codes, segments and c…
roncodes Sep 26, 2026
b6738c0
fix: allow a promotion end date without a start date
roncodes Sep 26, 2026
971427d
Merge branch 'feat/promotions-engine' into feat/promotion-campaigns
roncodes Sep 26, 2026
b8de7df
Merge branch 'feat/promotion-campaigns' into feat/promotions-console
roncodes Sep 26, 2026
c6d4f7a
chore(release): v0.4.22
roncodes Sep 28, 2026
a96a239
Merge pull request #101 from ispdomnet/main
roncodes Sep 28, 2026
9c039a5
Merge pull request #102 from fleetbase/fix/push-notification-layer
roncodes Sep 28, 2026
a29db54
Merge pull request #103 from fleetbase/feat/customer-notifications-inbox
roncodes Sep 28, 2026
65c5270
Merge branch 'release/v0.4.22' into feat/promotions-engine
roncodes Sep 28, 2026
c7fcf9d
Merge pull request #104 from fleetbase/feat/promotions-engine
roncodes Sep 28, 2026
211e0a8
Merge branch 'release/v0.4.22' into feat/promotion-campaigns
roncodes Sep 28, 2026
efd80ad
Merge pull request #105 from fleetbase/feat/promotion-campaigns
roncodes Sep 28, 2026
e33ef95
Merge pull request #106 from fleetbase/feat/promotions-console
roncodes Sep 28, 2026
6f8659d
fix: correct category selection and polish promotion workflows
roncodes Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 55 additions & 21 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,76 @@
> v0.4.21 ~ "QPay checkout restored and marketplace network repairs"
> v0.4.22 ~ "Reliable push notifications, a customer inbox, and promotions"

---
## Highlights

QPay checkout works again. v0.4.20 cached the QPay access token using `expires_in`, which QPay returns as an absolute timestamp rather than a lifetime in seconds — so the token was held far past its real life, and once QPay expired it every storefront request kept presenting a dead token and was answered with `NO_CREDENTIALS` even though the merchant credentials were valid. A token is minted per authentication again.
Push notifications reach Android and iOS devices again. Storefront's push code had several separate faults:
- A store's Firebase credentials were stored in a config slot the Firebase client never reads, so Android pushes could not be sent with them.
- iOS development and sandbox tokens were rejected.
- Marketplace orders were signed with the store's credentials instead of the network app's.
- A reinstalled or shared device kept notifying the previous customer.

Marketplace networks also pick up three repairs: saving a network with options, listing the categories its stores are grouped under, and creating those categories all failed in ways that only surfaced against a fully seeded marketplace.
Push delivery has been rebuilt on a single channel that picks the right credentials per device, retries tokens Apple or Google say belong to another app or environment, prunes dead tokens, and logs every failure.

---
## Fixes
Customers now have a **notifications inbox**. Order updates, promotions and campaigns are kept for the app to list, badge and mark as read, and new ones arrive in realtime.

- QPay authenticates fresh on every call, restoring checkout invoice creation, the `capture-qpay` callback, checkout status polling, and eBarimt receipts. Caching the token is only safe once the expiry timestamp is read as a timestamp, so it is gone rather than patched.
- Saving a `Network` with options no longer fails: the options mutator bypassed the model's JSON cast and stored a raw array.
- `Network::categories()` matched `for = 'network_category'`, a value nothing writes, so the relation was always empty. It now matches the `storefront_network` categories the console and the v1 API already use.
- `Network::createCategory()` derived its owner type from `network:storefront`, which resolves to no class. It now resolves to the `Network` model, so created categories are owned correctly.
Stores and networks get **promotions**:
- percentage off, amount off, free delivery, and buy X get Y discounts
- automatic or unlocked by promo codes
- targeting, schedules, usage limits, budgets and stacking rules
- priced into cash, Stripe and QPay checkouts alike

---
## Testing Fixtures
**Customer segments** and **campaigns** send targeted notifications now or on a schedule, and a promotion can be announced to customers when it starts. All of it is managed from new console screens under Promotions.

The storefront testing seeders are split into two complete, idempotent fixtures over a shared concern, both run by `TestingSeeder`:
---
## Features

- **Notifications inbox** (`storefront/v1/notifications`): list with `unread`/`type`/`limit`/`offset`, unread count, retrieve, mark one or all as read, delete. Notifications are scoped to the customer and to the storefront app they use. Realtime delivery goes over `contact.{uuid}`.
- **Notification preferences** (`notifications/preferences`): `order_updates` turns off order status pushes, and the inbox copy is kept. `promotions` turns off promotional notifications and campaigns entirely.
- **Promotions:** automatic or promo-code discounts owned by a store or network, with:
- product, category and store targeting
- minimum spend or items, and first order only
- date ranges and weekly time windows
- total, per-customer and per-code usage limits
- budgets and maximum discounts
- stacking and priorities
- **Checkout integration:** checkouts price promotions when they're created and reserve each use under a row lock, so limits and budgets can't be overspent and the amount charged always matches the discount shown. Captured orders record the discount on the transaction and order, and marketplace orders split it per store.
- **Public promotion endpoints:** `promotions` lists the storefront's live deals. Carts can apply, remove and preview promo codes (`carts/{id}/promo-code`, `carts/{id}/promotions`), and checkouts accept `promo_codes`.
- **Customer segments:** rule-based audiences by order count, recent or lapsed ordering, join date, spend and push reachability, with live audience previews.
- **Campaigns:** push and/or inbox messages with an image and deep link, sent to a segment, chosen customers or everyone, now or on a schedule, in queued batches. A promotion can be announced with a campaign that goes out when it starts.
- **Console:** Promotions, Campaigns and Segments screens, including promo-code generation and management.
- **Notification channel test:** a "Send test" action in the console shows Apple's or Google's exact answer for a pasted device token.
- **Ukrainian translation.**

- `StoreSeeder` seeds one standalone store end to end — order config, a store location with weekly hours, a sandbox Stripe gateway, categories, products covering variants, addons and sale, unavailable and draft cases, a published catalog, customers, an open cart, a pending checkout, orders spread across the last month, and reviews.
- `NetworkSeeder` seeds a marketplace: a network with its own sandbox gateway, network categories, member stores built the same way, and network-tagged orders across them.
---
## Fixes

This closes gaps that made local testing misleading: no gateway, location or hours were seeded, so Stripe checkout and delivery quoting could not be exercised at all; core product categories were never purged and duplicated on every run; addon categories had no owner, so the console never listed them; every order collapsed to `created` because the status is reset when the tracking number is generated; and all records shared a single timestamp. Each seeder now tags and purges only its own fixtures.
- **Firebase credentials:** FCM clients are built from the store's own Firebase service account. Previously the account JSON was written into `credentials.private_key` of the platform project and never worked. A store channel no longer needs the platform to have its own Firebase project either.
- **Marketplace pushes:** network orders use the network app's push credentials first, then the store's.
- **iOS environments:** tokens Apple rejects with `BadDeviceToken` are retried in the other APNs environment. APNs channels gain an `environment` setting (auto, production or sandbox).
- **Device registration:** registering a device moves the token to the customer who registered it most recently, normalizes the platform (`iOS` used to match nothing), and validates input. A new `customers/unregister-device` endpoint removes a token on sign out.
- **Push ordering and errors:** push is sent before mail and database delivery, so an email failure no longer stops it. A notification failure when auto-accepting an order is now logged instead of swallowed.
- **Payloads:** Android pushes are sent with high priority and an optional channel id, and iOS pushes play a sound.
- `Storefront::about()` returns `null` for an unknown storefront key instead of throwing.

---
## Upgrade Steps

No database migration is required.
1. Run the migrations. They add `promotions`, `promotion_codes`, `promotion_redemptions`, `customer_segments` and `campaigns`:

Nothing needs clearing after deploying: the fixed code never reads the cached QPay token, so any entry left by v0.4.20 is inert. Installations still on v0.4.20 that need QPay working before they can deploy can drop that entry to buy one token lifetime, but the upgrade is the fix:
```bash
php artisan migrate
```

```bash
php artisan cache:forget storefront:qpay:token:<md5 of "<qpay base uri>|<merchant username>">
```
2. **Scheduler:** make sure it runs. `storefront:dispatch-campaigns` runs every minute and `storefront:release-promotion-reservations` every 15 minutes.
3. **Queue worker:** make sure one runs. Campaigns are delivered in queued batches, and several order notifications were already queued.
4. **Optional:** fleetbase/core-api adds `user_devices.app_identifier`, `environment` and `last_seen_at`. With it, devices remember which app and APNs environment issued their token. Without it everything works and the server infers both.
5. **Optional:** set `STOREFRONT_PUSH_ANDROID_CHANNEL_ID`, or the per-channel `android_channel_id`, to post Android pushes to a specific notification channel.

Releases are now cut from `release/v0.0.0` branches instead of `dev-v0.0.0`. The tag workflow accepts both while open branches move over, so no in-flight release branch is stranded.
**Behavior changes to note:**
- **Order notification data:** order notifications stored in the database now keep the readable body in `message`; most types used to store the status code there. `type`, `title` and `body` are added, and every earlier key is kept.
- **Register device:** `POST customers/register-device` now requires `token` and a `platform` of `ios` or `android`.
- **Promotional push action:** the console's promotional push notification action now sends a campaign. It's recorded, respects customers' promotion preferences, also appears in their inbox, and is delivered by the queue.
- **Discount transaction item:** the discount is stored as a positive amount with `code: discount` and `meta.direction: credit`, because the Money cast drops signs. Anything that sums transaction items should subtract it.

---
## Need help?
Expand Down
1 change: 1 addition & 0 deletions addon/adapters/campaign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './storefront';
1 change: 1 addition & 0 deletions addon/adapters/customer-segment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './storefront';
1 change: 1 addition & 0 deletions addon/adapters/promotion-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './storefront';
1 change: 1 addition & 0 deletions addon/adapters/promotion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './storefront';
131 changes: 131 additions & 0 deletions addon/components/modals/campaign-form.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
<div class="p-4">
{{#if this.isReadOnly}}
<p class="mb-4 rounded-md bg-gray-50 p-3 text-sm text-gray-600 dark:bg-gray-800 dark:text-gray-300">{{t "storefront.promotions.campaigns.read-only-help"}}</p>
{{/if}}
<fieldset disabled={{this.isReadOnly}} class="form-wrapper min-w-0">
<InputGroup
@name={{t "storefront.promotions.common.name"}}
@value={{this.campaign.name}}
@placeholder={{t "storefront.promotions.campaigns.name-placeholder"}}
@helpText={{t "storefront.promotions.campaigns.name-help"}}
@required={{true}}
maxlength="191"
/>
<InputGroup
@name={{t "storefront.promotions.campaigns.title-label"}}
@value={{this.campaign.title}}
@placeholder={{t "storefront.promotions.campaigns.title-placeholder"}}
@required={{true}}
maxlength="191"
/>
<InputGroup @name={{t "storefront.promotions.campaigns.body-label"}} @required={{true}} as |id|>
<Textarea
id={{id}}
@value={{this.campaign.body}}
placeholder={{t "storefront.promotions.campaigns.body-placeholder"}}
class="form-input w-full"
rows="3"
maxlength="2000"
required
/>
</InputGroup>

<div class="border-t border-gray-200 pt-4 dark:border-gray-700 form-wrapper">
<InputGroup @name={{t "storefront.promotions.campaigns.audience"}} @helpText={{t "storefront.promotions.campaigns.audience-help"}} as |id|>
<ModelSelect
@modelName="customer-segment"
@selectedModel={{this.segment}}
@query={{hash owner=this.campaign.owner_uuid}}
@placeholder={{t "storefront.promotions.common.all-customers"}}
@triggerId={{id}}
@triggerClass="form-select form-input"
@renderInPlace={{true}}
@infiniteScroll={{false}}
@allowClear={{true}}
@disabled={{this.isReadOnly}}
@onChange={{this.setSegment}}
as |segment|
>
{{segment.name}}
</ModelSelect>
</InputGroup>
{{#if this.hasAudienceCount}}
<p class="text-sm text-gray-600 dark:text-gray-300">{{t "storefront.promotions.campaigns.audience-count" count=this.audienceCount}}</p>
{{/if}}
<InputGroup @name={{t "storefront.promotions.campaigns.channels"}} @helpText={{t "storefront.promotions.campaigns.channels-help"}} @required={{true}}>
<div class="flex flex-wrap gap-x-6 gap-y-3 py-1">
<Checkbox
@value={{this.hasChannel "push"}}
@label={{t "storefront.promotions.campaigns.channel-push"}}
@disabled={{this.isReadOnly}}
@onToggle={{fn this.toggleChannel "push"}}
/>
<Checkbox
@value={{this.hasChannel "inbox"}}
@label={{t "storefront.promotions.campaigns.channel-inbox"}}
@disabled={{this.isReadOnly}}
@onToggle={{fn this.toggleChannel "inbox"}}
/>
</div>
</InputGroup>
</div>

<div class="grid grid-cols-1 gap-4 border-t border-gray-200 pt-4 dark:border-gray-700 md:grid-cols-2">
<InputGroup @name={{t "storefront.promotions.campaigns.promotion"}} @helpText={{t "storefront.promotions.campaigns.promotion-help"}} as |id|>
<ModelSelect
@modelName="promotion"
@selectedModel={{this.promotion}}
@query={{hash owner=this.campaign.owner_uuid}}
@placeholder={{t "storefront.promotions.campaigns.promotion-placeholder"}}
@triggerId={{id}}
@triggerClass="form-select form-input"
@renderInPlace={{true}}
@infiniteScroll={{false}}
@allowClear={{true}}
@disabled={{this.isReadOnly}}
@onChange={{this.setPromotion}}
as |promotion|
>
{{promotion.name}}
</ModelSelect>
</InputGroup>
{{#unless this.promotion}}
<InputGroup @name={{t "storefront.promotions.campaigns.link-url"}} @helpText={{t "storefront.promotions.campaigns.link-help"}} as |id|>
<Input
id={{id}}
@type="url"
class="form-input w-full"
placeholder={{t "storefront.promotions.campaigns.link-placeholder"}}
@value={{this.linkUrl}}
{{on "input" this.setLinkUrl}}
/>
</InputGroup>
{{/unless}}
</div>

{{#if this.campaign.isEditable}}
<div class="border-t border-gray-200 pt-4 dark:border-gray-700 form-wrapper">
<InputGroup @name={{t "storefront.promotions.campaigns.when"}}>
<div class="flex flex-wrap gap-2">
{{#each this.whenOptions as |option|}}
<Button
@size="sm"
@type={{if (eq this.when option) "primary" "default"}}
@text={{t (concat "storefront.promotions.campaigns.when-" option)}}
@onClick={{fn this.setWhen option}}
aria-pressed={{if (eq this.when option) "true" "false"}}
/>
{{/each}}
</div>
</InputGroup>
{{#if (eq this.when "schedule")}}
<InputGroup @name={{t "storefront.promotions.campaigns.send-at"}} @helpText={{t "storefront.promotions.campaigns.schedule-help"}} @required={{true}}>
<DateTimeInput class="form-input w-full" @value={{this.campaign.send_at}} @onUpdate={{fn (mut this.campaign.send_at)}} />
</InputGroup>
{{/if}}
</div>
{{/if}}
</fieldset>
</div>
</Modal::Default>
Loading
Loading