Skip to content

feat(offer): Wallapop 'hacer oferta' flow — operator-tapped offers + negotiable band - #55

Merged
ifuensan merged 3 commits into
mainfrom
feat/wallapop-make-offer
Jul 23, 2026
Merged

feat(offer): Wallapop 'hacer oferta' flow — operator-tapped offers + negotiable band#55
ifuensan merged 3 commits into
mainfrom
feat/wallapop-make-offer

Conversation

@ifuensan

@ifuensan ifuensan commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Implements the OpenSpec change wallapop-make-offer end-to-end (29/29 tasks; artifacts in openspec/changes/wallapop-make-offer/, PRD amendment FR50–FR57).

What this adds

  • 💰 Ofertar button on Wallapop alerts — operator-tap only (the FR29 no-autonomous-action rule extends to offers). Tap → offer preflight → reconciliation re-fetch by internal listing id → amount recomputed with the Phase 2 tolerances → TinyFish drives the native "Hacer oferta" form with EXACTLY the bounded amount → 💰 Oferta enviada (or one of 12 closed OfferFailureReason variants, each fully rendered with the "No se ha enviado ninguna oferta." reassurance).
  • Negotiable-band alerts: Wallapop listings whose buyer total is over the entry ceiling but within ceiling × (1 + offer.band_pct) (default +20 %) on offer-enabled entries stop being silently filtered — new negotiable phase, 💰 severity token, Ofertar · Saltar · Ver keyboard, never Comprar. LLM evaluation + confidence gate unchanged.
  • Computed amount, never hand-picked: largest whole-euro item price whose delivered total (item + shipping + Protección) fits the entry target (offer.target_total_eur, default = ceiling), bounded by Wallapop's platform floor of 70 % of asking. Shown on the alert before the tap; drift at tap time aborts fail-closed.
  • Guardrails mirror the burn-in-proven buy path: one offer per listing ever (dedupe), self-imposed rolling-24 h budget (offer.daily_limit, default 5 — under Wallapop's 10/day account cap, whose on-form counter the agent captures into the audit row), independent lockout with its own offer_state row + kill switch (offer failures never block buys), append-only offers audit table (migration 0004), and the keyboard restored on EVERY outcome (the v0.4.3 lesson, inherited).
  • v1 ends at "offer sent": acceptance opens Wallapop's 24 h buy window (item NOT reserved meanwhile) — the alert copy says exactly that; follow-through happens in the app. Acceptance detection is the designed v2.
  • CLI: salvager offer enable <ref> [-t target] / disable [--all] / status (enable clears the lockout; status shows budget + lockout).
  • Platform rules operator-verified (app captures 2026-07-22 + web verification 2026-07-23, recorded in openspec/changes/wallapop-make-offer/captures/): listing-page button, /app/chat/offer?itemId=<internal id> form, comma-decimal amount field, 10/day counter, −30 % floor. Live API probe: is_refurbished exposed (now projected + pre-filtered — refurbished listings never render a dead button); no PRO flag (covered by offer_unavailable).

Compatibility

With no offer: block on any wishlist entry (the default), filtering, rendering, callbacks, and the daemon lifecycle are byte-identical to v0.4.4 — verified by the untouched pre-existing snapshot suites. Migration 0004 is additive; rollback = previous image (new tables ignored).

Test plan

  • Full gate green: ruff + format + mypy clean, 1366 tests pass (the 2 known /app sandbox failures excepted), openspec validate --strict OK.
  • New coverage: offer-amount unit tests (floor/boundary/buffer), migration 0004 additive test, append-only lint extended to OfferAuditWriter, negotiable-band e2e (7 cases incl. byte-identical off-path), TinyFish offer-flow adapter tests (18), orchestrator tests (12: every abort path, lockout at threshold, budget, keyboard restore), CLI tests (12), 34 golden snapshots for all 17 offer rendering surfaces (registry 45→66, pins updated with derivation).
  • Rendering re-audit posture: zero drift in the 49 pre-existing reference texts; the new surfaces are queued for the criterion-3 scope in docs/release-audits/v1.0/SUMMARY.md ("Pending delta").

🤖 Generated with Claude Code

https://claude.ai/code/session_01Djngz3hScLAcRoyKtBCZgb

Summary by CodeRabbit

  • New Features
    • Added a Wallapop operator-tap “💰 Ofertar” flow with computed offer amounts, “negotiable band” listing alerts, and offer sent/failure notifications.
    • Added per-wishlist opt-in offer settings plus CLI enable/disable/status controls, rolling 24h budget, and offer lockout/kill-switch behavior.
  • Documentation
    • Updated README and example configuration/wishlist docs with eligibility rules, tap-time recomputation, and operational guardrails.
  • Tests
    • Added unit, snapshot, and end-to-end coverage for offer pricing, rendering, execution orchestration, auditing, CLI controls, and failure/lockout scenarios.

…negotiable band

Implements OpenSpec change wallapop-make-offer (FR50-FR57):

- 💰 Ofertar button (operator-tap only, FR29 extended): tap → offer
  preflight → re-fetch by internal id → amount recompute with tolerance
  → TinyFish drives the native offer form with EXACTLY the bounded
  amount → 'Oferta enviada' / closed 12-variant failure taxonomy.
- Negotiable-band alerts: Wallapop listings over ceiling but within
  ceiling×(1+offer.band_pct) on offer-enabled entries stop being
  silently filtered (new 'negotiable' phase, 💰 severity token).
- Amount = largest whole-euro item price whose buyer total fits the
  entry target (offer.target_total_eur, default ceiling), bounded by
  Wallapop's -30% floor (operator-verified app+web, 2026-07-22/23).
- Guardrails mirror the buy path: per-listing dedupe (one offer ever),
  rolling-24h daily budget (default 5, under Wallapop's 10/day cap),
  independent lockout (offer_state, migration 0004, append-only offers
  table), keyboard restored on every outcome, kill switch.
- Wishlist offer: block + salvager offer enable/disable/status CLI;
  is_refurbished projected from the search payload and pre-filtered.
- Variant registry 45→66 with golden snapshots; PRD amendment FR50-57.

Zero behaviour change until an entry opts in via 'salvager offer enable'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djngz3hScLAcRoyKtBCZgb
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in, operator-triggered Wallapop “hacer oferta” flow with computed pricing, negotiable-band alerts, TinyFish execution, Telegram callbacks, audit persistence, lockout controls, CLI commands, daemon wiring, and extensive validation.

Changes

Wallapop offer capability

Layer / File(s) Summary
Offer contracts, configuration, and rollout specification
openspec/changes/wallapop-make-offer/*, README.md, _bmad-output/..., docs/release-audits/..., config.example.yaml, src/salvager/templates/*, wishlist.example.yaml
Defines operator-only offers, buyer-total pricing, negotiable-band eligibility, failure states, keyboard lifecycle, append-only auditing, lockout behavior, configuration, CLI semantics, and rollout documentation.
Offer domain, persistence, and rendering contracts
src/salvager/domain/*, src/salvager/interfaces/offer_session.py, src/salvager/migrations/0004_offer_schema.sql, src/salvager/adapters/sqlite_store/offer_writer.py, src/salvager/config/*
Adds offer settings, pricing and failure models, refurbishment state, offer result contracts, validated configuration, additive SQLite tables, append-only attempt storage, deduplication, rolling budgets, and mutable lockout state.
Wallapop offer adapter
src/salvager/adapters/tinyfish_browser/*, src/salvager/adapters/wallapop_api/*
Adds bounded TinyFish offer execution with strict response validation, exact amount verification, failure mapping, and refurbishment-state propagation.
Negotiable alerts and live updates
src/salvager/orchestration/poll_loop.py, src/salvager/orchestration/alert_updater.py, src/salvager/domain/alert.py
Adds buyer-total negotiable-band filtering, computed offer rows, offer deduplication, negotiable alert rendering, live eligibility recomputation, and keyboard reconstruction.
Offer callback and guarded execution
src/salvager/orchestration/callback_handler.py, src/salvager/orchestration/offer_preflight.py, src/salvager/orchestration/offer_orchestrator.py
Routes offer callbacks through preflight, listing reconciliation, TinyFish execution, auditing, lockout handling, outcome rendering, and keyboard restoration.
Daemon wiring and operator controls
src/salvager/orchestration/composer.py, src/salvager/cli/app.py, src/salvager/cli/commands/offer_cmd.py
Wires offer resources into the daemon and adds offer enable, offer disable, bulk disabling, and offer status operations.
Fixtures and validation
tests/*, src/salvager/cli/dev_alert_fixtures.py
Adds end-to-end, unit, snapshot, migration, adapter, CLI, orchestration, pricing, audit, and append-only writer coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant Telegram
  participant CallbackDispatcher
  participant OfferOrchestrator
  participant OfferPreflight
  participant WallapopOfferFlow
  participant OfferAuditWriter
  Operator->>Telegram: Tap Ofertar
  Telegram->>CallbackDispatcher: offer callback
  CallbackDispatcher->>OfferOrchestrator: execute offer
  OfferOrchestrator->>OfferPreflight: check eligibility
  OfferOrchestrator->>WallapopOfferFlow: submit reconciled amount
  WallapopOfferFlow-->>OfferOrchestrator: success or failure
  OfferOrchestrator->>OfferAuditWriter: append attempt
  OfferOrchestrator->>Telegram: send outcome and restore keyboard
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: the new Wallapop offer flow with operator-tapped offers and negotiable-band support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wallapop-make-offer

Comment @coderabbitai help to get the list of available commands.

CI runs 'ruff format --check .' and 'mypy src tests' (wider than the
local gate I ran): format the goal-text edit and annotate the new offer
test helpers/fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djngz3hScLAcRoyKtBCZgb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/salvager/orchestration/poll_loop.py (1)

409-475: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Already-offered negotiable listings are re-evaluated by the LLM every cycle, forever.

_filter_over_ceiling's negotiable-band condition never checks the offer dedupe (has_offered/has_successful_offer); has_offered isn't even threaded into this function. Every negotiable-eligible listing therefore pays an LLM-eval cost every cycle regardless of whether it was already successfully offered. The dedupe only happens downstream in _dispatch_alert (line 733-741), which then skips silently via return False — but unlike every other skip/drop branch in this module (e.g. Line 489, Line 338), that skip path never calls store.record_seen, so the listing is never marked seen and repeats this cycle-after-cycle indefinitely (until it ages out of search results or drops under the ceiling).

Thread has_offered into _filter_over_ceiling and exclude/record-seen already-offered listings there — this both saves the wasted LLM eval and closes the missing record_seen gap in one place.

🩹 Proposed fix (sketch)
 async def _filter_over_ceiling(
     buyable: list[Listing],
     entry: WishlistEntry,
     store: Store,
     summary: PollCycleSummary,
     *,
     assumed_shipping_eur: Decimal,
     assumed_import_charges_eur: Decimal,
     marketplace: Marketplace,
     log: object,
     offer_band_pct: Decimal | None = None,
+    has_offered: Callable[[str, str], Awaitable[bool]] | None = None,
 ) -> tuple[list[Listing], list[Listing]]:
     ...
         if (
             offer_band_pct is not None
             and entry.offer.enabled
             and listing.marketplace == "wallapop"
             and not listing.is_refurbished
             and total <= ceiling * (1 + offer_band_pct)
             and offer_item_price_eur(...) is not None
+            and not (
+                has_offered is not None
+                and await has_offered(listing.marketplace, listing.listing_id)
+            )
         ):
             negotiable.append(listing)
             ...

And pass has_offered=has_offered at the _filter_over_ceiling call site (Line 278-288), which already has it available.

Also applies to: 733-741

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/salvager/orchestration/poll_loop.py` around lines 409 - 475, Thread the
existing has_offered state into _filter_over_ceiling and its call site, then
exclude listings already offered from the negotiable bucket while calling
store.record_seen for them. Preserve normal within-ceiling handling and ensure
the downstream _dispatch_alert dedupe path is no longer responsible for these
listings.
🧹 Nitpick comments (3)
src/salvager/orchestration/offer_orchestrator.py (2)

494-497: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

assert used to enforce a domain invariant consumed immediately after.

ceiling feeds straight into Decimal arithmetic (target = entry.offer.target_total_eur or _entry_ceiling(entry)); if wishlist validation ever regresses and -O is used, this silently returns None instead of failing loudly.

♻️ Proposed fix
 def _entry_ceiling(entry: WishlistEntry) -> Decimal:
     ceiling = entry.max_price_solo or entry.max_price_in_device
-    assert ceiling is not None, "wishlist validation guarantees a ceiling"
+    if ceiling is None:
+        raise ValueError(f"entry {entry.entry_key} has no price ceiling — wishlist validation should have caught this")
     return ceiling
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/salvager/orchestration/offer_orchestrator.py` around lines 494 - 497,
Replace the assert in _entry_ceiling with explicit runtime validation so a
missing ceiling always fails before Decimal arithmetic, including when Python
runs with -O. Preserve the existing preference for max_price_solo over
max_price_in_device and raise an appropriate exception with clear context when
both are absent.

1-1: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Exception text is truncated in logs but not in the ctx that reaches the operator's Telegram alert. All three sites log str(exc) (truncated to 200 chars where shown) but pass the untruncated str(exc) into a ctx["detail"] that render_offer_failure/the offer-failure alert eventually renders to the operator — a large or unusual exception message can produce an oversized or noisy Telegram alert.

  • src/salvager/orchestration/offer_orchestrator.py#L252-260: truncate fail_ctx["detail"] (e.g. str(exc)[:200]) to match the log line at Line 254.
  • src/salvager/orchestration/offer_orchestrator.py#L400-414: truncate ctx["detail"] in _handle_unexpected the same way.
  • src/salvager/adapters/tinyfish_browser/wallapop_offer.py#L264-269: truncate the SDKError ctx "detail": str(exc) before it's attached to OfferSendFailure.ctx.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/salvager/orchestration/offer_orchestrator.py` at line 1, Truncate
exception details to 200 characters before storing them in operator-facing
contexts. Update the failure context at the offer orchestration site,
`_handle_unexpected`, and the `SDKError` context in the Wallapop offer adapter
so each `ctx["detail"]` uses the same bounded text as the corresponding logs,
while preserving the existing alert flow.

Source: Learnings

tests/unit/test_audit_writer_append_only.py (1)

74-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated append-only test scaffolding — extract a shared helper.

The new OfferAuditWriter block re-implements the same three checks (no update_*/delete_* methods, exact public-surface match, static UPDATE/DELETE scan) already written for audit_writer above, with an almost identical regex and introspection helper. A small parametrized helper would avoid re-copying this scaffolding for the next *AuditWriter.

♻️ Sketch: shared parametrized helper
def _public_methods(cls: type) -> set[str]:
    return {
        name
        for name, _ in inspect.getmembers(cls, predicate=inspect.isfunction)
        if not name.startswith("_")
    }


def _assert_no_sql_mutates(module, tables: frozenset[str]) -> None:
    source = inspect.getsource(module)
    mutated = re.findall(r"\b(?:UPDATE|DELETE\s+FROM)\s+([a-z_]+)", source, flags=re.IGNORECASE)
    offenders = sorted(t for t in mutated if t in tables)
    assert offenders == [], f"append-only tables must never be UPDATE/DELETE'd: {offenders}"


`@pytest.mark.parametrize`(
    ("module", "cls", "tables", "expected_methods"),
    [
        (audit_writer, Phase2AuditWriter, _APPEND_ONLY_TABLES, _EXPECTED_PUBLIC_METHODS),
        (offer_writer, OfferAuditWriter, _OFFER_APPEND_ONLY_TABLES, _EXPECTED_OFFER_PUBLIC_METHODS),
    ],
)
def test_append_only_contract(module, cls, tables, expected_methods) -> None:
    methods = _public_methods(cls)
    assert not any(m.startswith(("update_", "delete_")) for m in methods)
    assert methods == set(expected_methods)
    _assert_no_sql_mutates(module, tables)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_audit_writer_append_only.py` around lines 74 - 130, Extract
the duplicated append-only checks into shared helpers near the existing
audit-writer tests: use a generic _public_methods helper and an
_assert_no_sql_mutates helper, then replace the separate Phase2AuditWriter and
OfferAuditWriter tests with one parametrized test_append_only_contract covering
each module, class, table set, and expected method set. Preserve the existing
forbidden-method, exact-public-surface, and SQL-mutation assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@_bmad-output/planning-artifacts/prd.md`:
- Around line 794-805: Resolve the duplicate requirement identifiers in the PRD
by renumbering the pre-existing SIGTERM and distribution requirements
immediately following the Wallapop offer-flow amendment, preserving FR50–FR57
exclusively for the offer requirements. Update all references to the renumbered
requirements across the specification, tasks, and README so traceability remains
consistent.

In `@openspec/changes/wallapop-make-offer/design.md`:
- Around line 31-32: Update the negotiable-band predicate documentation to
require both band membership and a computable floor-valid offer before retaining
a listing. Apply this clarification in
openspec/changes/wallapop-make-offer/design.md lines 31-32,
openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md lines
5-7, and README.md lines 191-192; explicitly note that some otherwise in-band
listings remain filtered when no valid offer meets the floor.

In `@openspec/changes/wallapop-make-offer/proposal.md`:
- Line 39: Update the proposal’s configuration module reference from
config/config_yaml.py to src/salvager/config/config_yaml.py, while preserving
the existing references to the example configuration files and bundled template.

In `@src/salvager/cli/app.py`:
- Around line 924-949: Update cmd_offer_status to load the configured
offer.daily_limit from config.yaml and pass it explicitly as daily_limit to
offer_cmd.run_status, rather than relying on run_status’s hardcoded default.
Preserve the existing wishlist path, data directory, output format, and
exit-code handling.

In `@src/salvager/cli/commands/offer_cmd.py`:
- Around line 241-248: Update the offer status command wiring in app.py to pass
the configured offer.daily_limit value into run_status instead of relying on its
default of 5. Preserve run_status’s existing footer and JSON reporting behavior,
using the configured value for both outputs.

In `@src/salvager/domain/listing.py`:
- Around line 67-72: Ensure every Listing projection, especially the detail
construction in WallapopFetcher, preserves the source refurbishment status
instead of defaulting missing values to False. Update the offer eligibility gate
to allow offers only when is_refurbished is explicitly False, using a tri-state
representation if the source value can be unknown.

In `@src/salvager/orchestration/offer_orchestrator.py`:
- Around line 300-325: Handle exceptions from _record_attempt in the
OfferSuccess branch separately from send failures. After execute_offer succeeds,
report audit persistence failure as a critical degradation and return a
success-shaped or dedicated “sent but unaudited” outcome instead of routing
through _fail, resetting/rearming retry state, or presenting the offer as
unsent. Preserve normal audit, counter-reset, and dispatch behavior when
recording succeeds.

---

Outside diff comments:
In `@src/salvager/orchestration/poll_loop.py`:
- Around line 409-475: Thread the existing has_offered state into
_filter_over_ceiling and its call site, then exclude listings already offered
from the negotiable bucket while calling store.record_seen for them. Preserve
normal within-ceiling handling and ensure the downstream _dispatch_alert dedupe
path is no longer responsible for these listings.

---

Nitpick comments:
In `@src/salvager/orchestration/offer_orchestrator.py`:
- Around line 494-497: Replace the assert in _entry_ceiling with explicit
runtime validation so a missing ceiling always fails before Decimal arithmetic,
including when Python runs with -O. Preserve the existing preference for
max_price_solo over max_price_in_device and raise an appropriate exception with
clear context when both are absent.
- Line 1: Truncate exception details to 200 characters before storing them in
operator-facing contexts. Update the failure context at the offer orchestration
site, `_handle_unexpected`, and the `SDKError` context in the Wallapop offer
adapter so each `ctx["detail"]` uses the same bounded text as the corresponding
logs, while preserving the existing alert flow.

In `@tests/unit/test_audit_writer_append_only.py`:
- Around line 74-130: Extract the duplicated append-only checks into shared
helpers near the existing audit-writer tests: use a generic _public_methods
helper and an _assert_no_sql_mutates helper, then replace the separate
Phase2AuditWriter and OfferAuditWriter tests with one parametrized
test_append_only_contract covering each module, class, table set, and expected
method set. Preserve the existing forbidden-method, exact-public-surface, and
SQL-mutation assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95b35c96-35db-4036-85a3-8f429ccc682b

📥 Commits

Reviewing files that changed from the base of the PR and between 51d61aa and 0006272.

⛔ Files ignored due to path filters (4)
  • openspec/changes/wallapop-make-offer/captures/app-listing-hacer-oferta-button.jpg is excluded by !**/*.jpg
  • openspec/changes/wallapop-make-offer/captures/app-offer-form-10-restantes.jpg is excluded by !**/*.jpg
  • openspec/changes/wallapop-make-offer/captures/app-offer-form-floor-30pct.jpg is excluded by !**/*.jpg
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (60)
  • README.md
  • _bmad-output/planning-artifacts/prd.md
  • config.example.yaml
  • docs/release-audits/v1.0/SUMMARY.md
  • openspec/changes/wallapop-make-offer/.openspec.yaml
  • openspec/changes/wallapop-make-offer/captures/NOTES.md
  • openspec/changes/wallapop-make-offer/design.md
  • openspec/changes/wallapop-make-offer/proposal.md
  • openspec/changes/wallapop-make-offer/specs/listing-alert-state-updates/spec.md
  • openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md
  • openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md
  • openspec/changes/wallapop-make-offer/tasks.md
  • openspec/config.yaml
  • src/salvager/adapters/sqlite_store/offer_writer.py
  • src/salvager/adapters/telegram_bot/surface.py
  • src/salvager/adapters/tinyfish_browser/__init__.py
  • src/salvager/adapters/tinyfish_browser/wallapop_offer.py
  • src/salvager/adapters/wallapop_api/fetcher.py
  • src/salvager/adapters/wallapop_api/schema.py
  • src/salvager/cli/app.py
  • src/salvager/cli/commands/dev_cmd.py
  • src/salvager/cli/commands/offer_cmd.py
  • src/salvager/cli/dev_alert_fixtures.py
  • src/salvager/config/config_yaml.py
  • src/salvager/config/wishlist_yaml.py
  • src/salvager/domain/alert.py
  • src/salvager/domain/audit.py
  • src/salvager/domain/errors.py
  • src/salvager/domain/listing.py
  • src/salvager/domain/offer_audit.py
  • src/salvager/domain/pricing.py
  • src/salvager/domain/wishlist.py
  • src/salvager/interfaces/offer_session.py
  • src/salvager/migrations/0004_offer_schema.sql
  • src/salvager/orchestration/alert_updater.py
  • src/salvager/orchestration/callback_handler.py
  • src/salvager/orchestration/composer.py
  • src/salvager/orchestration/offer_orchestrator.py
  • src/salvager/orchestration/offer_preflight.py
  • src/salvager/orchestration/poll_loop.py
  • src/salvager/templates/config.example.yaml
  • src/salvager/templates/wishlist.example.yaml
  • tests/e2e/test_poll_loop_offer_band.py
  • tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr
  • tests/unit/__snapshots__/test_operational_alert_renderer.ambr
  • tests/unit/test_alert_renderer.py
  • tests/unit/test_audit_writer_append_only.py
  • tests/unit/test_callback_handler.py
  • tests/unit/test_cli_offer.py
  • tests/unit/test_dev_emit_alert.py
  • tests/unit/test_offer_orchestrator.py
  • tests/unit/test_offer_pricing.py
  • tests/unit/test_offer_renderer_snapshots.py
  • tests/unit/test_offer_writer.py
  • tests/unit/test_operational_alert_renderer.py
  • tests/unit/test_phase2_schema.py
  • tests/unit/test_sqlite_store.py
  • tests/unit/test_tinyfish_offer_flow.py
  • tests/unit/test_wallapop_api_fetcher.py
  • wishlist.example.yaml

Comment on lines +794 to 805

**Wallapop offer flow (wallapop-make-offer amendment, 2026-07-22):**

- **FR50.** The operator can send a price offer ("hacer oferta") on a Wallapop listing by tapping a `💰 Ofertar` inline button; no offer is ever sent without an operator tap (the FR29 no-autonomous-action rule extends to offers).
- **FR51.** The offer amount is computed, not chosen: the largest whole-euro item price whose delivered buyer total fits the entry's offer target (`offer.target_total_eur`, defaulting to the entry ceiling), bounded by Wallapop's platform floor of 70 % of the asking price, shown on the alert before the tap and recomputed from the reconciled listing at tap time.
- **FR52.** Wallapop listings on offer-enabled entries whose buyer total exceeds the ceiling but sits within `ceiling × (1 + offer.band_pct)` produce a distinct negotiable alert (`💰` severity token, offer row, `Ofertar · Saltar · Ver` keyboard, never Comprar) instead of being silently filtered; listings beyond the band, on offer-disabled entries, or on eBay filter exactly as before.
- **FR53.** Offer sending enforces a self-imposed daily budget (`offer.daily_limit`, default 5 per rolling 24 h, deliberately under Wallapop's 10-per-calendar-day account cap) and recognises the platform's own exhausted-counter state; neither limit-hit increments the failure lockout.
- **FR54.** At most one successful offer is ever sent per listing (per-listing dedupe); a listing with a sent offer keeps a terminal `💰 Oferta enviada` badge across keyboard reconstructions.
- **FR55.** Offer outcomes use the closed `OfferFailureReason` set, each variant rendered with a Spanish cause label, detail rows, next steps, and the reassurance line "No se ha enviado ninguna oferta." (with a documented ambiguity variant for missing confirmation evidence); v1 ends at "offer sent" — seller responses are handled by the operator in the Wallapop app.
- **FR56.** Consecutive offer execution failures reaching `offer.lockout_threshold` disable the offer path globally until `salvager offer enable <entry>` clears the lockout; the offer lockout and `offer.kill_switch_global` are fully independent from the Phase 2 circuit breaker and kill switch.
- **FR57.** Offers are opt-in per wishlist entry (`offer.enabled`, default false, toggled via `salvager offer enable/disable`); with no entry opted in, alert filtering, rendering, and callbacks are byte-identical to the pre-offer behaviour, and every executed offer attempt is recorded in the append-only `offers` audit table.
- **FR50.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Duplicate FR ids: FR50-FR54 now used twice with unrelated content.

The new offer-flow amendment claims FR50-FR57, but FR50 (SIGTERM handling, line 805) and FR51-FR54 (distribution artifacts, lines 809-812) already exist unchanged immediately below with the same ids. This breaks the FR-traceability contract this section states — every doc referencing "FR50-FR57" for the offer flow (the spec files, tasks.md, README) now also collides with the SIGTERM/distribution requirements.

🔧 Proposed fix: renumber the pre-existing FRs that follow the insertion point
-- **FR50.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.
+- **FR58.** The agent handles SIGTERM gracefully — drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds.

 ### Project Distribution & Artifacts

-- **FR51.** The repository ships a single `docker-compose.yml` install path with example wishlist entries for common HDD and RAM models, an `.env.example`, and a `config.example.yaml`; user-specific files (`wishlist.yaml`, `config.yaml`, `.env`) are gitignored.
-- **FR52.** The repository includes a `CONTRIBUTING.md` with an explicit "no arbitrage PRs" rule and three named invitation categories (wishlist examples, prompt improvements, Wallapop selector patches), pointing to a separate-repo path for arbitrage forks.
-- **FR53.** The repository includes a `ROADMAP.md` naming future-multi-marketplace expansion, future-arbitrage-as-separate-repo, and "C&D-induced sunset" as a documented possible end state.
-- **FR54.** The README positions salvager as a personal monitoring tool (not a "Wallapop scraper"), includes a legal disclaimer covering Spanish ToS posture and the secondary-account recommendation, and contains no Wallapop trademarks, logos, or proprietary terms in titles, package names, or domain references.
+- **FR59.** The repository ships a single `docker-compose.yml` install path with example wishlist entries for common HDD and RAM models, an `.env.example`, and a `config.example.yaml`; user-specific files (`wishlist.yaml`, `config.yaml`, `.env`) are gitignored.
+- **FR60.** The repository includes a `CONTRIBUTING.md` with an explicit "no arbitrage PRs" rule and three named invitation categories (wishlist examples, prompt improvements, Wallapop selector patches), pointing to a separate-repo path for arbitrage forks.
+- **FR61.** The repository includes a `ROADMAP.md` naming future-multi-marketplace expansion, future-arbitrage-as-separate-repo, and "C&D-induced sunset" as a documented possible end state.
+- **FR62.** The README positions salvager as a personal monitoring tool (not a "Wallapop scraper"), includes a legal disclaimer covering Spanish ToS posture and the secondary-account recommendation, and contains no Wallapop trademarks, logos, or proprietary terms in titles, package names, or domain references.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@_bmad-output/planning-artifacts/prd.md` around lines 794 - 805, Resolve the
duplicate requirement identifiers in the PRD by renumbering the pre-existing
SIGTERM and distribution requirements immediately following the Wallapop
offer-flow amendment, preserving FR50–FR57 exclusively for the offer
requirements. Update all references to the renumbered requirements across the
specification, tasks, and README so traceability remains consistent.

Comment on lines +31 to +32
**D3 — Negotiable band = a carve-out at the existing over-ceiling filter, upstream of evaluation.**
`poll_loop._filter_over_ceiling` (pre-eval) gains one branch: a Wallapop listing on an offer-enabled entry whose buyer total is over the ceiling but ≤ `ceiling × (1 + offer.band_pct)` (config, default 0.20) is tagged *negotiable* and kept; everything beyond the band is dropped exactly as today. Tagged listings flow through the unchanged LLM evaluation and confidence gate — junk in the band must not alert just because it is cheap-ish. At render time the tag selects the negotiable renderer: distinct severity token, the standard buyer-total breakdown, plus an offer line (`💰 Oferta: 74 € (total ≤ 80,00 €)` shape) and the Ofertar row — never Comprar (over ceiling by definition; the Phase 2 preflight would reject it anyway). *Cost note:* the band widens the LLM-eval funnel; bounded by per-entry opt-in and the band width. Seen-listing dedupe applies unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the negotiable-band predicate explicit everywhere.

Runtime filtering requires both band membership and a computable floor-valid offer, while these documents describe band membership alone.

  • openspec/changes/wallapop-make-offer/design.md#L31-L32: add the valid-offer condition.
  • openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md#L5-L7: update the carve-out requirement.
  • README.md#L191-L192: explain why some in-band listings remain filtered.
📍 Affects 3 files
  • openspec/changes/wallapop-make-offer/design.md#L31-L32 (this comment)
  • openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md#L5-L7
  • README.md#L191-L192
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/wallapop-make-offer/design.md` around lines 31 - 32, Update
the negotiable-band predicate documentation to require both band membership and
a computable floor-valid offer before retaining a listing. Apply this
clarification in openspec/changes/wallapop-make-offer/design.md lines 31-32,
openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md lines
5-7, and README.md lines 191-192; explicitly note that some otherwise in-band
listings remain filtered when no valid offer meets the floor.

Comment thread openspec/changes/wallapop-make-offer/proposal.md Outdated
Comment thread src/salvager/cli/app.py
Comment thread src/salvager/cli/commands/offer_cmd.py
Comment thread src/salvager/domain/listing.py
Comment thread src/salvager/orchestration/offer_orchestrator.py
…renumber, status denominator

- CRITICAL: a failed offers-table write AFTER a verified send no longer
  masquerades as a send failure (which rendered the false reassurance,
  counted a lockout failure, and left the dedupe unrecorded). The
  outcome stays success; the missing audit row escalates via an
  offer_orchestrator_error operational alert.
- Renumber the offer PRD amendment FR50-FR57 → FR58-FR65 (FR50-FR54
  were already taken by the ops/repo sections) and update every
  reference in code, tests, and specs.
- 'offer status' now threads config.offer.daily_limit into the budget
  denominator (--config-path, best-effort load).
- fetch_listing carries the search-derived is_refurbished forward (the
  detail payload doesn't expose the flag) so offer eligibility never
  widens on a re-fetch.
- Spec deltas state the negotiable-band predicate explicitly (band AND
  computable floor-valid offer); proposal path typo fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djngz3hScLAcRoyKtBCZgb
@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/salvager/adapters/wallapop_api/fetcher.py`:
- Around line 305-310: Preserve the prior reservation state in the detail
re-fetch mapping alongside is_refurbished: pass listing.is_reserved into the
constructed Listing because WallapopApiItemDetail does not provide that field,
while allowing a future API value to override it if available. Add a regression
test covering a reserved listing through detail re-fetch and verifying the offer
flow remains ineligible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8471ef25-45ba-4431-af46-b7c0f345674d

📥 Commits

Reviewing files that changed from the base of the PR and between 0c6216f and c6421b6.

📒 Files selected for processing (15)
  • _bmad-output/planning-artifacts/prd.md
  • openspec/changes/wallapop-make-offer/proposal.md
  • openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md
  • openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md
  • src/salvager/adapters/tinyfish_browser/wallapop_offer.py
  • src/salvager/adapters/wallapop_api/fetcher.py
  • src/salvager/cli/app.py
  • src/salvager/cli/commands/offer_cmd.py
  • src/salvager/domain/alert.py
  • src/salvager/interfaces/offer_session.py
  • src/salvager/orchestration/offer_orchestrator.py
  • src/salvager/orchestration/offer_preflight.py
  • tests/unit/test_alert_renderer.py
  • tests/unit/test_offer_orchestrator.py
  • tests/unit/test_offer_renderer_snapshots.py
🚧 Files skipped from review as they are similar to previous changes (13)
  • tests/unit/test_offer_renderer_snapshots.py
  • openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md
  • tests/unit/test_alert_renderer.py
  • src/salvager/cli/app.py
  • src/salvager/interfaces/offer_session.py
  • _bmad-output/planning-artifacts/prd.md
  • src/salvager/adapters/tinyfish_browser/wallapop_offer.py
  • src/salvager/cli/commands/offer_cmd.py
  • src/salvager/orchestration/offer_preflight.py
  • openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md
  • tests/unit/test_offer_orchestrator.py
  • src/salvager/orchestration/offer_orchestrator.py
  • src/salvager/domain/alert.py

Comment on lines +305 to +310
# The detail payload does NOT expose the refurbished flag
# (live-probed 2026-07-22) — carry the search-derived value
# forward instead of silently resetting it to False, so the
# offer path's eligibility never widens on a re-fetch. The
# agent goal's missing-button check is the runtime backstop.
is_refurbished=listing.is_refurbished,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A25 -B5 'is_reserved|class Listing' src/salvager/domain/listing.py src/salvager/adapters/wallapop_api/schema.py tests/unit/test_wallapop_api_fetcher.py tests/unit/test_offer_orchestrator.py

Repository: ifuensan/salvager

Length of output: 15778


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Wallapop fetcher around the detail reconciliation and the offer gate.
sed -n '250,390p' src/salvager/adapters/wallapop_api/fetcher.py
printf '\n----\n'
sed -n '1,220p' src/salvager/domain/listing.py
printf '\n----\n'
sed -n '1,220p' src/salvager/domain/offer_orchestrator.py
printf '\n----\n'
rg -n -A8 -B4 'reserved|is_reserved|detail' src/salvager/adapters/wallapop_api

Repository: ifuensan/salvager

Length of output: 10563


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the detail payload schema and any tests covering detail re-fetch behavior.
rg -n -A12 -B6 'class WallapopApiItemDetail|reserved|is_reserved|is_refurbished|detail' src/salvager/adapters/wallapop_api/schema.py tests/unit/test_wallapop_api_fetcher.py

Repository: ifuensan/salvager

Length of output: 20256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the detail schema and the code that builds a Listing from detail data.
sed -n '1,220p' src/salvager/adapters/wallapop_api/schema.py
printf '\n----\n'
sed -n '250,360p' src/salvager/adapters/wallapop_api/fetcher.py
printf '\n----\n'
rg -n -A6 -B4 'WallapopApiItemDetail|reserved|is_reserved|detail' src/salvager/adapters/wallapop_api/schema.py src/salvager/adapters/wallapop_api/fetcher.py

Repository: ifuensan/salvager

Length of output: 23522


Preserve is_reserved during detail re-fetch. WallapopApiItemDetail has no reserved field, and Listing.is_reserved defaults to False; dropping it here can turn a reserved listing into an eligible one and let the offer flow proceed. Carry the prior value through (or map it if the API adds it later) and add a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/salvager/adapters/wallapop_api/fetcher.py` around lines 305 - 310,
Preserve the prior reservation state in the detail re-fetch mapping alongside
is_refurbished: pass listing.is_reserved into the constructed Listing because
WallapopApiItemDetail does not provide that field, while allowing a future API
value to override it if available. Add a regression test covering a reserved
listing through detail re-fetch and verifying the offer flow remains ineligible.

@ifuensan
ifuensan merged commit 07604db into main Jul 23, 2026
9 checks passed
@ifuensan
ifuensan deleted the feat/wallapop-make-offer branch July 23, 2026 11:37
ifuensan added a commit that referenced this pull request Jul 23, 2026
Version bump + CHANGELOG entry for #55/#56; README status blurb and
recommended pinned tag, ROADMAP burn-in tag → 0.5.0.


Claude-Session: https://claude.ai/code/session_01Djngz3hScLAcRoyKtBCZgb

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant