Skip to content

Commit 18e4e16

Browse files
feat(cancel): distinguish user-initiated cancels from counterparty inactivity timeouts
1 parent 0b4f402 commit 18e4e16

3 files changed

Lines changed: 48 additions & 12 deletions

File tree

lib/features/notifications/utils/notification_data_extractor.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import 'package:mostro_mobile/shared/providers.dart';
1010
class NotificationDataExtractor {
1111
/// Extract notification data from MostroMessage
1212
/// If ref is null, will use fallback methods for nickname resolution
13-
static Future<NotificationData?> extractFromMostroMessage(MostroMessage event, Ref? ref, {Session? session, Status? previousStatus}) async {
13+
static Future<NotificationData?> extractFromMostroMessage(MostroMessage event, Ref? ref, {Session? session, Status? previousStatus, bool wasUserInitiatedCancel = false}) async {
1414
Map<String, dynamic> values = {};
1515
bool isTemporary = false;
1616

@@ -148,10 +148,12 @@ class NotificationDataExtractor {
148148
break;
149149

150150
case Action.canceled:
151-
// Persist cancellations in notification history with a variant message
152-
// based on the order's previous status, so the user can review what
153-
// happened to the order later from the notifications screen.
154-
if (previousStatus != null) {
151+
// Persist cancellations in notification history. Only attach
152+
// previous_status (which drives the inactivity-specific message)
153+
// when the cancel was NOT user-initiated; manual cancels in
154+
// waiting-payment / waiting-buyer-invoice should fall back to the
155+
// generic message instead of falsely blaming the counterparty.
156+
if (!wasUserInitiatedCancel && previousStatus != null) {
155157
values['previous_status'] = previousStatus.value;
156158
}
157159
break;

lib/features/order/notifiers/abstract_mostro_notifier.dart

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
2525
// Timer storage for orphan session cleanup
2626
static final Map<String, Timer> _sessionTimeouts = {};
2727

28+
// Tracks orderIds where the user just clicked cancel, so the resulting
29+
// Action.canceled response can be distinguished from a counterparty
30+
// inactivity timeout (which uses the same action).
31+
static final Set<String> _userInitiatedCancels = <String>{};
32+
33+
/// Records that the user just sent a manual cancel for [orderId].
34+
/// The flag is consumed when the corresponding Action.canceled is processed.
35+
static void markUserInitiatedCancel(String orderId) {
36+
_userInitiatedCancels.add(orderId);
37+
}
38+
2839
AbstractMostroNotifier(
2940
this.orderId,
3041
this.ref, {
@@ -70,6 +81,12 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
7081
// counterparty-specific cancellation reason).
7182
final previousStatus = state.status;
7283

84+
// Consume the user-initiated cancel flag (set by cancelOrder)
85+
// so Action.canceled responses can be distinguished from
86+
// counterparty inactivity timeouts, which use the same action.
87+
final wasUserInitiatedCancel = msg.action == Action.canceled &&
88+
_userInitiatedCancels.remove(orderId);
89+
7390
if (mounted) {
7491
state = state.updateWith(msg);
7592
}
@@ -80,7 +97,9 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
8097
.millisecondsSinceEpoch) {
8198
logger.i(
8299
'Message timestamp check passed, calling handleEvent for ${msg.action}');
83-
unawaited(handleEvent(msg, previousStatus: previousStatus));
100+
unawaited(handleEvent(msg,
101+
previousStatus: previousStatus,
102+
wasUserInitiatedCancel: wasUserInitiatedCancel));
84103
} else {
85104
logger.w(
86105
'Message timestamp check failed for ${msg.action}. Timestamp: ${msg.timestamp}, Current: ${DateTime.now().millisecondsSinceEpoch}, Threshold: ${DateTime.now().subtract(const Duration(seconds: 60)).millisecondsSinceEpoch}');
@@ -93,7 +112,8 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
93112
'Processing dispute action ${msg.action} despite old timestamp (state update only)');
94113
unawaited(handleEvent(msg,
95114
bypassTimestampGate: true,
96-
previousStatus: previousStatus));
115+
previousStatus: previousStatus,
116+
wasUserInitiatedCancel: wasUserInitiatedCancel));
97117
}
98118
}
99119
}
@@ -126,7 +146,9 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
126146
}
127147

128148
Future<void> handleEvent(MostroMessage event,
129-
{bool bypassTimestampGate = false, Status? previousStatus}) async {
149+
{bool bypassTimestampGate = false,
150+
Status? previousStatus,
151+
bool wasUserInitiatedCancel = false}) async {
130152
// Skip if we've already processed this exact event
131153
final eventKey = '${event.id}_${event.action}_${event.timestamp}';
132154
if (_processedEventIds.contains(eventKey)) {
@@ -147,7 +169,9 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
147169
// Extract notification data using the centralized extractor
148170
final notificationData =
149171
await NotificationDataExtractor.extractFromMostroMessage(event, ref,
150-
session: session, previousStatus: previousStatus);
172+
session: session,
173+
previousStatus: previousStatus,
174+
wasUserInitiatedCancel: wasUserInitiatedCancel);
151175

152176
// Only notify for recent events; old disputes still update state below
153177
if (notificationData != null && (isRecent || !bypassTimestampGate)) {

lib/features/order/notifiers/order_notifier.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@ class OrderNotifier extends AbstractMostroNotifier {
2323

2424
@override
2525
Future<void> handleEvent(MostroMessage event,
26-
{bool bypassTimestampGate = false, Status? previousStatus}) async {
26+
{bool bypassTimestampGate = false,
27+
Status? previousStatus,
28+
bool wasUserInitiatedCancel = false}) async {
2729
logger.i('OrderNotifier received event: ${event.action} for order $orderId');
2830

2931
// Handle the event normally - timeout/cancellation logic is now in AbstractMostroNotifier
3032
await super.handleEvent(event,
3133
bypassTimestampGate: bypassTimestampGate,
32-
previousStatus: previousStatus);
34+
previousStatus: previousStatus,
35+
wasUserInitiatedCancel: wasUserInitiatedCancel);
3336
}
3437

3538
Future<void> sync() async {
@@ -121,6 +124,10 @@ class OrderNotifier extends AbstractMostroNotifier {
121124
}
122125

123126
Future<void> cancelOrder() async {
127+
// Flag this cancel as user-initiated so the upcoming Action.canceled
128+
// response is not misattributed to counterparty inactivity when the
129+
// order is in waiting-payment / waiting-buyer-invoice.
130+
AbstractMostroNotifier.markUserInitiatedCancel(orderId);
124131
await mostroService.cancelOrder(orderId);
125132
}
126133

@@ -184,12 +191,15 @@ class OrderNotifier extends AbstractMostroNotifier {
184191
final sessionNotifier = ref.read(sessionNotifierProvider.notifier);
185192
await sessionNotifier.deleteSession(orderId);
186193

187-
// Persist expiration in notification history (and show SnackBar)
194+
// Persist expiration in notification history (and show SnackBar).
195+
// Use a stable eventId so repeated public-event emissions for the
196+
// same auto-expiration are deduplicated by the notifications store.
188197
final notifProvider = ref.read(notificationActionsProvider.notifier);
189198
await notifProvider.notify(
190199
Action.canceled,
191200
values: {'previous_status': Status.pending.value},
192201
orderId: orderId,
202+
eventId: 'auto_expire:$orderId',
193203
);
194204

195205
ref.invalidateSelf();

0 commit comments

Comments
 (0)