Skip to content

Commit f4d9f59

Browse files
authored
refactor(banners): Replace the payment-failure evaluator with a generic backend command intake (#4909)
## Context The stack was deciding when a payment problem deserves a banner. It read the Stripe subscription status and the invoice attempt count off the bus, ran the escalation rules itself, and picked the wording from its own translation catalog. None of that is ours: the stack should be writing banners, not deciding them. Every wording change or rule change meant a stack release, and the billing rules already live on the backend side that owns the subscription. ## Solution `billing.go`, the `BillingLifecycleMessage` contract, its handler, its queue and the four billing translations are removed. In their place a backend publishes a `banner.materialize` or a `banner.clear` command on the `platform` exchange, carrying the wording it already decided, and the stack validates it and stores it. `docs/banners.md` describes that contract for the producing side. The intake is not a pass-through. A command is refused if its category is not listed in `banner_command_categories` for the instance context, and the `quota` category is refused always, because the stack measures disk usage itself and no backend should be able to speak for it. The routing key decides materialize versus clear, never the payload, so a producer cannot clear a category by naming a field. The CTA has to be an absolute `https` URL, the payload has to fit `MaxCommandBytes`, and the `bannerId` has to match the format the doctype documents. Ordering is the part worth reading. Delivery is at-least-once and unordered, so the intake needs to know what it already applied. That record is a new stack-private doctype, `io.cozy.banners.commands`, blocklisted so no application can reach it, holding the last accepted revision per category. It is separate from `io.cozy.banners` on purpose: an app has write access there to carry a dismissal, and if the ordering rode on the visible document that app could rewind it. A stale revision is refused and the same revision applied twice converges, so a redelivery is a no-op. A command that retrying cannot fix is nacked and the broker dead letters it after `delivery_limit` attempts. The second commit is unrelated and reads on its own. `Extend()` returned nothing, so the renewal goroutine of a long operation could not tell a renewed lease from a lost one: it kept renewing a lease Redis no longer held, and it outlived `Unlock`. It now returns an error and the goroutine stops on the first failure. The two callers, the RAG workspace and the VFS migration, do not observe the lease themselves, so for them this is a goroutine that stops instead of one that lies. ## Worth knowing Tested locally against a real broker: materialize inline and modal, clear, a stale revision refused, and malformed and reserved-category commands dead lettered. `TestHandlers/InstallApp` fails, but it fails identically on master. One gap is open and I do not think it blocks: a commanded banner does not re-localize when the user changes their language, because we keep only the locale picked when the command arrived. The backend sends the full set, we drop the rest. Follow-up. The old `stack.billing.lifecycle` queue and its dead letter queue stay declared on the broker. That is a deployment cleanup, not a code change.
2 parents 89fc111 + 3d0c7d6 commit f4d9f59

30 files changed

Lines changed: 3061 additions & 1088 deletions

assets/locales/en.po

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,18 +1345,3 @@ msgstr "AI Assistant"
13451345

13461346
msgid "Banners Quota Exceeded Text"
13471347
msgstr "You have reached your storage limit."
1348-
1349-
1350-
msgid "Banners Billing Restricted Text"
1351-
msgstr "We couldn't process your payment after several attempts. Some features are now limited for your organization and your team may not be able to perform certain actions.\n\nYour data remains safe and unchanged. Full access will be restored instantly once your payment is updated."
1352-
1353-
msgid "Banners Billing CTA Label"
1354-
msgstr "Update payment method"
1355-
1356-
1357-
1358-
msgid "Banners Billing Restricted Title"
1359-
msgstr "Your workspace is temporarily restricted"
1360-
1361-
msgid "Banners Billing Support Label"
1362-
msgstr "Contact support"

assets/locales/fr.po

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,15 +1458,3 @@ msgstr "Voir les détails du fichier"
14581458

14591459
msgid "Banners Quota Exceeded Text"
14601460
msgstr "Vous avez atteint la limite de votre espace de stockage."
1461-
1462-
msgid "Banners Billing Restricted Title"
1463-
msgstr "Votre espace de travail est temporairement restreint"
1464-
1465-
msgid "Banners Billing Restricted Text"
1466-
msgstr "Nous n'avons pas pu traiter votre paiement après plusieurs tentatives. Certaines fonctionnalités sont désormais limitées pour votre organisation et votre équipe peut ne plus pouvoir effectuer certaines actions.\n\nVos données restent intactes et en sécurité. L'accès complet sera rétabli dès la mise à jour de votre paiement."
1467-
1468-
msgid "Banners Billing CTA Label"
1469-
msgstr "Mettre à jour le moyen de paiement"
1470-
1471-
msgid "Banners Billing Support Label"
1472-
msgstr "Contacter le support"

assets/locales/ru.po

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,15 +1406,3 @@ msgstr "Someone shared a folder with you:"
14061406

14071407
msgid "Banners Quota Exceeded Text"
14081408
msgstr "Вы достигли лимита хранилища."
1409-
1410-
msgid "Banners Billing Restricted Title"
1411-
msgstr "Ваше рабочее пространство временно ограничено"
1412-
1413-
msgid "Banners Billing Restricted Text"
1414-
msgstr "Нам не удалось обработать ваш платёж после нескольких попыток. Некоторые функции теперь ограничены для вашей организации, и ваша команда может не иметь возможности выполнять определённые действия.\n\nВаши данные в безопасности и не изменены. Полный доступ будет восстановлен сразу после обновления платежа."
1415-
1416-
msgid "Banners Billing CTA Label"
1417-
msgstr "Обновить способ оплаты"
1418-
1419-
msgid "Banners Billing Support Label"
1420-
msgstr "Связаться со службой поддержки"

assets/locales/vi.po

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,15 +1378,3 @@ msgstr "Someone shared a folder with you:"
13781378

13791379
msgid "Banners Quota Exceeded Text"
13801380
msgstr "Bạn đã đạt đến giới hạn dung lượng lưu trữ."
1381-
1382-
msgid "Banners Billing Restricted Title"
1383-
msgstr "Không gian làm việc của bạn tạm thời bị hạn chế"
1384-
1385-
msgid "Banners Billing Restricted Text"
1386-
msgstr "Chúng tôi không thể xử lý khoản thanh toán của bạn sau nhiều lần thử. Một số tính năng hiện bị hạn chế đối với tổ chức của bạn và nhóm của bạn có thể không thực hiện được một số thao tác.\n\nDữ liệu của bạn vẫn an toàn và không thay đổi. Quyền truy cập đầy đủ sẽ được khôi phục ngay khi khoản thanh toán của bạn được cập nhật."
1387-
1388-
msgid "Banners Billing CTA Label"
1389-
msgstr "Cập nhật phương thức thanh toán"
1390-
1391-
msgid "Banners Billing Support Label"
1392-
msgstr "Liên hệ bộ phận hỗ trợ"

cozy.example.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,25 @@ contexts:
548548
# Feature flags
549549
features:
550550
- hide_konnector_errors
551+
# Platform banners (io.cozy.banners) for the instances of this context.
552+
# See docs/banners.md.
553+
banner:
554+
# Off by default, so the rules can ship before the clients that render
555+
# them. Turning it back off stops the writes and leaves the documents
556+
# already materialized in place.
557+
enabled: true
558+
# The categories the stack.banner.commands queue is allowed to write, or
559+
# "*" for all of them. Its publisher is authenticated by its broker
560+
# credentials and bindings, so this says what it may say, not who it
561+
# is. The quota category is always refused: the stack measures disk
562+
# usage itself.
563+
command_categories:
564+
- billing
565+
- trial
566+
# The hosts a banner command's call to action may link to. There is no
567+
# wildcard.
568+
cta_hosts:
569+
- manager.example.org
551570
# List of applications that can be automatically updated even if the
552571
# permissions have changed
553572
additional_platform_apps:
@@ -759,3 +778,24 @@ rabbitmq:
759778
delivery_limit: 5
760779
bindings:
761780
- app.installation.requested
781+
# Banner commands. The exchange is a deployment choice: an existing one
782+
# with dedicated bindings works too, as long as both repositories name the
783+
# same one. The dead letter queue is where a malformed or unauthorized
784+
# command lands, so it has to exist for those to be inspectable.
785+
- name: platform
786+
kind: topic
787+
durable: true
788+
declare_exchange: false
789+
queues:
790+
- name: stack.banner.commands
791+
declare: true
792+
declare_dlx: true
793+
declare_dlq: true
794+
dlx_name: stack.platform.dlx
795+
dlq_name: stack.dead.letter.banner.commands
796+
dl_routing_key: banner.commands.dead
797+
prefetch: 8
798+
delivery_limit: 5
799+
bindings:
800+
- banner.materialize
801+
- banner.clear

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Feel free to [open an issue](https://github.com/cozy/cozy-stack/issues/new) for
4848

4949
### Up-to-date
5050

51+
- [Banners](banners.md)
5152
- [Flagship app](flagship.md)
5253
- [Move design](move-design.md)
5354
- [Realtime internals](realtime-internals.md)

docs/banners.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
## Banners
2+
3+
This is the publisher reference for backend banner commands. The stack stores
4+
one `io.cozy.banners` document per category and instance. See
5+
[ADR 054](https://github.com/linagora/twake-workplace-private/blob/main/documentation/docs/adrs/adr-054.md)
6+
for the platform design.
7+
8+
### Configuration
9+
10+
Enable banners and allow the publisher's categories in each recipient context:
11+
12+
```yaml
13+
contexts:
14+
b2b_twake_default:
15+
banner:
16+
enabled: true
17+
command_categories:
18+
- billing
19+
- trial
20+
cta_hosts:
21+
- manager.example.org
22+
```
23+
24+
Set `command_categories` to `["*"]` to allow every category except `quota`.
25+
`cta_hosts` has no wildcard.
26+
27+
Broker credentials, permissions and bindings control who can publish. Each
28+
category must have one owner and one addressing mode: the stack keeps one
29+
revision per instance and category, shared by `orgId` and `workplaceFqdn`
30+
commands, so never address a category both ways. `quota` is reserved for the
31+
stack's rules.
32+
33+
Instances that disable banners are skipped. Instances whose context does not
34+
list the category or a CTA host are skipped with a warning log. Other eligible
35+
recipients still receive the command. Skipping an instance leaves its existing
36+
documents and recorded revision unchanged. An error on one instance
37+
does not stop processing the others. The stack returns all failures after
38+
attempting every recipient, so delivery can be retried.
39+
40+
### Commands
41+
42+
Publish JSON on the `platform` exchange, consumed by `stack.banner.commands`.
43+
The routing key selects the operation:
44+
45+
- `banner.materialize`: create or replace the banner in a category.
46+
- `banner.clear`: expire the banner in a category while retaining its revision; nonempty presentation fields
47+
are rejected.
48+
49+
See [RabbitMQ configuration](rabbitmq.md#configuration) for queue declarations
50+
and [shared fixtures](../model/banner/testdata) for complete examples.
51+
52+
`banner.materialize`:
53+
54+
```json
55+
{
56+
"workplaceFqdn": "alice.twake.app",
57+
"eventId": "banner-command-42",
58+
"revision": 42,
59+
"timestamp": 1788944400,
60+
"category": "billing",
61+
"bannerId": "billing.grace.cycle-a.attempt-2",
62+
"severity": "warning",
63+
"surface": "banner",
64+
"dismissible": true,
65+
"text": { "en": "We could not charge your card.", "fr": "Nous n'avons pas pu débiter votre carte." },
66+
"cta": {
67+
"label": { "en": "Update payment method", "fr": "Mettre à jour le moyen de paiement" },
68+
"url": "https://manager.example.org/linagora/twake_prod/premium"
69+
}
70+
}
71+
```
72+
73+
`banner.clear`:
74+
75+
```json
76+
{
77+
"workplaceFqdn": "alice.twake.app",
78+
"eventId": "banner-command-43",
79+
"revision": 43,
80+
"timestamp": 1788944400,
81+
"category": "billing"
82+
}
83+
```
84+
85+
| Field | Required | Contract |
86+
| --- | --- | --- |
87+
| `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. |
88+
| `workplaceFqdn` / `orgId` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `orgId` is at most 256 bytes with no surrounding whitespace. |
89+
| `revision` | always | Positive counter, increasing per category. |
90+
| `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. |
91+
| `eventId` | no | Correlation ID, at most 256 bytes. |
92+
| `bannerId` | materialize | Matches `^[a-z0-9.-]{1,64}$`. Keep it for the same occurrence to preserve dismissal; change it for a new occurrence. |
93+
| `severity` | materialize | `info`, `warning` or `error`. |
94+
| `surface` | materialize | `banner` or `modal`. |
95+
| `text` | materialize | Locale map with nonempty `en`; at most 1024 bytes per locale. |
96+
| `title` | no | Locale map with nonempty `en` when supplied; at most 256 bytes per locale. |
97+
| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes) whose host is in `banner.cta_hosts`. A secondary CTA requires a primary one. |
98+
| `dismissible` | no | Defaults to false. A modal without a CTA is made dismissible. |
99+
| `priority` | no | 0–1000; defaults to 0. Quota banners use 50 and 100. |
100+
| `startsAt`, `endsAt` | no | RFC3339. If both are supplied, `startsAt` must precede `endsAt`. An explicit start replaces the stored start; omission preserves it for the same occurrence when compatible with the end, otherwise defaults to the command's decision time. |
101+
102+
Each locale map accepts at most 32 locales with keys of 1–35 bytes. The JSON
103+
body is limited to 256 KiB, including whitespace and unknown fields.
104+
`_id`, `_rev`, `dismissedAt` and `cozyMetadata` are not command fields and are
105+
ignored if supplied.
106+
107+
### Localization
108+
109+
The publisher supplies all wording. The stack selects the instance's locale
110+
only if it is complete for every supplied text and label; otherwise the whole
111+
banner falls back to `en`. The stored `lang` identifies the selected language.
112+
Any complete publisher-supplied locale is supported, independently of the
113+
stack's translation catalogs.
114+
115+
On an instance language change, existing banners are re-localized from retained
116+
commands in the banner documents without republishing. Cleared or deleted
117+
banners and older records without retained wording are left unchanged.
118+
119+
### Revisions and recovery
120+
121+
Commanded banners store `revision`, `eventId`, and the full localized command
122+
in `accepted` alongside their presentation. A clear retains the category's
123+
document with `cleared: true`, an expired `endsAt`, and no retained wording;
124+
clients must filter out banners whose validity window has ended. A newer
125+
materialize replaces it normally. Updating the command revision also updates
126+
the document revision, even when its visible wording is unchanged.
127+
128+
These fields use the same app permissions as the banner. Apps recording a
129+
dismissal should preserve the other fields and use the current CouchDB `_rev`;
130+
editing or deleting the ordering state can allow stale commands to be replayed.
131+
132+
- Revisions at or below the last accepted revision for an instance and category
133+
are ignored, even after a clear. Only a changed decision needs a new revision;
134+
the publisher must ensure newer revisions carry newer state.
135+
- Retry with the original revision, event ID and payload. Replays complete
136+
partial organization deliveries and reach newly provisioned members while
137+
leaving recipients that already accepted the revision unchanged.
138+
- Enabling banners does not bootstrap them: the publisher must republish.
139+
- Invalid commands and missing workplaces fail delivery. The broker requeues
140+
failures without a delay until its configured delivery limit is exhausted;
141+
configure dead lettering as described in [RabbitMQ](rabbitmq.md#dead-letter-exchange-dlx-and-dead-letter-queue-dlq).
142+
Fix the cause and explicitly replay dead-lettered commands with their original
143+
operation routing key (`banner.materialize` or `banner.clear`).
144+
- The stack sends no application acknowledgement. A broker confirm means the
145+
broker accepted the message, not that a banner was stored or displayed.

docs/rabbitmq.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,23 @@ rabbitmq:
149149
delivery_limit: 5
150150
bindings:
151151
- app.installation.requested
152+
- name: platform
153+
kind: topic
154+
durable: true
155+
declare_exchange: false
156+
queues:
157+
- name: stack.banner.commands
158+
declare: true
159+
declare_dlx: true
160+
declare_dlq: true
161+
dlx_name: stack.platform.dlx
162+
dlq_name: stack.dead.letter.banner.commands
163+
dl_routing_key: banner.commands.dead
164+
prefetch: 8
165+
delivery_limit: 5
166+
bindings:
167+
- banner.materialize
168+
- banner.clear
152169
```
153170
154171
### Dead Letter Exchange (DLX) and Dead Letter Queue (DLQ)
@@ -267,12 +284,19 @@ type Handler interface {
267284

268285
Returning `nil` acknowledges the message. Returning a non-nil error causes the message to be requeued (subject to broker policies and delivery limits).
269286

287+
A handler does not classify its errors. Every failure is nacked with requeue,
288+
and the queue's `delivery_limit` is what bounds the retries: once it is reached
289+
the broker dead letters the message. So a payload that does not parse costs a
290+
few redeliveries before it lands in the dead letter queue, and a storage failure
291+
gets those same attempts to succeed.
292+
270293
Queue names are mapped to handlers in the stack. For example:
271294

272295
- `user.password.updated` → updates an instance passphrase when a `user.password.updated` routing key is received.
273296
- `user.created` → validates and processes user creation events.
274297
- `user.phone.updated` → updates the phone number stored in user settings.
275298
- `domain.user.deleted` on the `b2b` exchange → removes externally managed organization contacts.
299+
- `banner.materialize` and `banner.clear` on the `platform` exchange → materializes or clears a platform banner, see [Banners](banners.md).
276300

277301
Message schemas are JSON and validated in the handler. Example payload for `user.password.updated`:
278302

model/banner/banner.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package banner
88

99
import (
10+
"maps"
1011
"time"
1112

1213
"github.com/cozy/cozy-stack/pkg/consts"
@@ -78,6 +79,13 @@ type Banner struct {
7879
EndsAt *time.Time `json:"endsAt,omitempty"`
7980
Source Source `json:"source"`
8081

82+
// Command state shares the document's ordinary app permissions. A clear
83+
// expires the banner instead of deleting its ordering history.
84+
Revision int64 `json:"revision,omitempty"`
85+
EventID string `json:"eventId,omitempty"`
86+
Cleared bool `json:"cleared,omitempty"`
87+
Accepted *Command `json:"accepted,omitempty"`
88+
8189
Metadata *metadata.CozyMetadata `json:"cozyMetadata,omitempty"`
8290
}
8391

@@ -111,6 +119,30 @@ func (b *Banner) clone() *Banner {
111119
at := *b.EndsAt
112120
cloned.EndsAt = &at
113121
}
122+
if b.Accepted != nil {
123+
cmd := *b.Accepted
124+
cmd.Title = maps.Clone(cmd.Title)
125+
cmd.Text = maps.Clone(cmd.Text)
126+
if cmd.CTA != nil {
127+
cta := *cmd.CTA
128+
cta.Label = maps.Clone(cta.Label)
129+
cmd.CTA = &cta
130+
}
131+
if cmd.SecondaryCTA != nil {
132+
cta := *cmd.SecondaryCTA
133+
cta.Label = maps.Clone(cta.Label)
134+
cmd.SecondaryCTA = &cta
135+
}
136+
if cmd.StartsAt != nil {
137+
at := *cmd.StartsAt
138+
cmd.StartsAt = &at
139+
}
140+
if cmd.EndsAt != nil {
141+
at := *cmd.EndsAt
142+
cmd.EndsAt = &at
143+
}
144+
cloned.Accepted = &cmd
145+
}
114146
if b.Metadata != nil {
115147
cloned.Metadata = b.Metadata.Clone()
116148
}

0 commit comments

Comments
 (0)