Skip to content

feat(hangar): buy-back links, price, insurance and availability - #5473

Merged
mortik merged 15 commits into
mainfrom
feat/5471-buyback-details
Oct 7, 2026
Merged

mortik merged 15 commits into
mainfrom
feat/5471-buyback-details

Conversation

@mortik

@mortik mortik commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Resolves #5471. The decisions and the RSI markup they rest on are in the issue.

What changed

  • Links: every row on /hangar/buybacks gets a "Buy back on RSI" link that opens in a new tab. Packages, ships, paints and add-ons go to /pledge/buyback/<id>. An upgrade's buy-back is a modal on RSI's list page with no URL of its own, so upgrades link to /account/buy-back-pledges.
  • Availability: the list sync reads article.pledge[data-disabled], and the row gets a "Not available" tag.
  • Upgrade price: the to-ship's pledge_price minus the from-ship's, in USD, joined on models.rsi_id. It is computed when the list is read and never stored, so it follows our price sync.
  • Other price and insurance: after the list is stored, the sync answers with detailsPending, the non-upgrade pledges with no details yet. The modal first reads the account's store pricing through the sync extension (currency, exchange rate and tax rate, the rates RSI converts the account's prices with). Then it reads each pledge's RSI buy-back page and divides the page price back into RSI's USD figure. Results go to the new PUT /hangar/sync-rsi-buyback-details in batches of 25. That is the original purchase price, read once and never refreshed. A page whose currency the pricing does not cover is not stored. A pledge page with no price, or one RSI answers 404 for, is stored without one.
  • Every price is USD before tax, upgrades included, so it compares with every other price on FleetYards. There is no currency column.
  • The detail pass shares the list crawl's limit of 60 requests a minute. Three unreadable pages in a row stop it, keeping whatever was already stored, and the next sync carries on. The modal can be closed while it runs.
  • An extension without the new actions still syncs the list and is asked to update for prices.
  • New columns on buyback_pledges: available, price (USD), insurance_months, lifetime_insurance, details_synced_at.
  • docs/findings/rsi-buyback-page.md now covers the detail page, the account pricing and the conversion back to USD, the availability marker, an account with no buy-backs, and RSI's upgrade price API (with why we don't use it).

Needs fleetyards/sync#164 released before ship and package prices and insurance show up in production. Until then the page has links, availability and upgrade prices.

Currency: an account priced in another currency is converted back with RSI's own rates. The rates' scale (8800 meaning 0.88) is inferred from one EUR account and checked against six pledges, not documented by RSI. An unscaled rate is refused rather than guessed.

Testing

  • bin/rails test test/integration/api/v1/hangar_buybacks_test.rb test/models/validation_error_test.rb: 32 passed
  • Vitest on useBuybackDetailsSync, components/Hangar, the buy-back parsers and RSIStorePricing: 84 passed
  • pnpm lint:ts passes. pnpm lint:fix reports two errors in useFilters.spec.ts, which is unchanged from main.
  • The migration was never run locally: db:migrate is blocked here, so schema.rb and the annotations were written by hand and the test database was loaded from them.
  • I checked the RSI markup by hand in a signed-in browser. I did not run the extension end to end with this page, and did not check the row layout in a browser.

🤖

mortik added 6 commits October 7, 2026 16:18
…surance

The list sync takes each pledge's availability and answers with the
pledges that have no details yet; a second endpoint stores the price
and insurance read from RSI for them.
Reads the RSI buy-back page of each pledge the sync has no details for,
and RSI's upgrade price for each upgrade, through the sync extension.
Details are stored as they arrive, so a pass that stops keeps its
progress; three unreadable pages in a row stop it.
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 20285cb1-038e-428c-befc-156c0257acb8
📥 Commits

Reviewing files that changed from the base of the PR and between 93a6a30 and 75c552f.

📒 Files selected for processing (53)
  • app/api_components/v1/schemas/hangar/buyback_details_sync_result.rb
  • app/api_components/v1/schemas/hangar/buyback_pledge.rb
  • app/api_components/v1/schemas/hangar/buyback_sync_result.rb
  • app/api_components/v1/schemas/inputs/rsi_buyback_detail_input.rb
  • app/api_components/v1/schemas/inputs/rsi_buyback_item_input.rb
  • app/api_components/v1/schemas/inputs/sync_rsi_buyback_details_input.rb
  • app/controllers/api/v1/hangar_buybacks_controller.rb
  • app/frontend/frontend/components/Hangar/BuybackSyncBtn/Modal/index.spec.ts
  • app/frontend/frontend/components/Hangar/BuybackSyncBtn/Modal/index.vue
  • app/frontend/frontend/components/Hangar/BuybacksList/index.spec.ts
  • app/frontend/frontend/components/Hangar/BuybacksList/index.vue
  • app/frontend/frontend/composables/useBuybackDetailsSync.spec.ts
  • app/frontend/frontend/composables/useBuybackDetailsSync.ts
  • app/frontend/frontend/composables/useSyncExtension.ts
  • app/frontend/frontend/lib/FleetyardsSyncHandler.ts
  • app/frontend/frontend/lib/RSIBuybackDetailParser.spec.ts
  • app/frontend/frontend/lib/RSIBuybackDetailParser.ts
  • app/frontend/frontend/lib/RSIBuybackParser.spec.ts
  • app/frontend/frontend/lib/RSIBuybackParser.ts
  • app/frontend/frontend/lib/RSIStorePricing.spec.ts
  • app/frontend/frontend/lib/RSIStorePricing.ts
  • app/frontend/translations/de/labels.json
  • app/frontend/translations/de/texts.json
  • app/frontend/translations/en/labels.json
  • app/frontend/translations/en/texts.json
  • app/frontend/translations/es/labels.json
  • app/frontend/translations/es/texts.json
  • app/frontend/translations/fr/labels.json
  • app/frontend/translations/fr/texts.json
  • app/frontend/translations/it/labels.json
  • app/frontend/translations/it/texts.json
  • app/frontend/translations/zh-CN/labels.json
  • app/frontend/translations/zh-CN/texts.json
  • app/frontend/translations/zh-TW/labels.json
  • app/frontend/translations/zh-TW/texts.json
  • app/lib/buyback_pledges/store_details.rb
  • app/lib/buyback_pledges/sync.rb
  • app/models/buyback_pledge.rb
  • app/policies/buyback_pledge_policy.rb
  • app/views/api/v1/hangar_buybacks/_buyback_pledge.jbuilder
  • config/locales/de/validation_error.yml
  • config/locales/en/validation_error.yml
  • config/locales/es/validation_error.yml
  • config/locales/fr/validation_error.yml
  • config/locales/it/validation_error.yml
  • config/locales/zh-CN/validation_error.yml
  • config/locales/zh-TW/validation_error.yml
  • config/routes/api/hangar_routes.rb
  • db/migrate/20261007160000_add_details_to_buyback_pledges.rb
  • db/schema.rb
  • docs/findings/rsi-buyback-page.md
  • swagger/v1/schema.yaml
  • test/integration/api/v1/hangar_buybacks_test.rb
📝 Walkthrough

Walkthrough

Buy-back sync now records availability and identifies pledges that need detail reads. The frontend reads RSI detail pages and submits price and insurance data through a new API endpoint. The buy-back list displays those details and links available pledges to RSI.

Changes

Buy-back data and availability

Layer / File(s) Summary
List sync and buy-back fields
app/lib/buyback_pledges/sync.rb, app/models/buyback_pledge.rb, app/views/api/v1/hangar_buybacks/_buyback_pledge.jbuilder, db/migrate/*, db/schema.rb, app/api_components/v1/schemas/hangar/*, swagger/v1/schema.yaml, app/frontend/frontend/lib/RSIBuybackParser.ts, test/integration/api/v1/hangar_buybacks_test.rb, docs/findings/rsi-buyback-page.md
List sync stores availability and returns IDs for unsynced, non-upgrade details. Pledge responses include availability and insurance fields. Upgrade prices use the difference between the associated ships’ pledge prices. The migration adds availability, price, currency, insurance, and detail-sync timestamp columns.
Detail-sync endpoint and persistence
app/api_components/v1/schemas/inputs/*buyback*, app/api_components/v1/schemas/hangar/buyback_details_sync_result.rb, app/controllers/api/v1/hangar_buybacks_controller.rb, app/lib/buyback_pledges/store_details.rb, app/policies/buyback_pledge_policy.rb, config/routes/api/hangar_routes.rb, config/locales/*/validation_error.yml, swagger/v1/schema.yaml, test/integration/api/v1/hangar_buybacks_test.rb
The new endpoint accepts up to 100 detail records and reports the number of updated pledges. It stores submitted details for matching pledges owned by the caller. The API schema and localized validation errors describe the endpoint and storage failures.
RSI detail retrieval and sync UI
app/frontend/frontend/lib/RSIBuybackDetailParser.ts, app/frontend/frontend/composables/useBuybackDetailsSync.ts, app/frontend/frontend/composables/useSyncExtension.ts, app/frontend/frontend/lib/FleetyardsSyncHandler.ts, app/frontend/frontend/components/Hangar/BuybackSyncBtn/Modal/*, app/frontend/translations/*/texts.json, app/frontend/translations/*/labels.json, docs/findings/rsi-buyback-page.md
The frontend parses RSI detail pages and sends detail requests through the syncBuybackDetail extension action. The modal tracks detail progress, shares its request budget with list sync, and handles unsupported extensions, cancellation, and incomplete reads.
Buy-back list display
app/frontend/frontend/components/Hangar/BuybacksList/*, app/frontend/translations/*/labels.json
The list displays availability, formatted prices, insurance, and reclamation dates. Available pledges link to RSI; unavailable pledges have no RSI action.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant BuybackSyncModal
  participant useBuybackDetailsSync
  participant FleetyardsSyncHandler
  participant RSIBuybackDetailParser
  participant HangarBuybacksController
  participant StoreDetails
  BuybackSyncModal->>useBuybackDetailsSync: run pending detail pass
  useBuybackDetailsSync->>FleetyardsSyncHandler: request syncBuybackDetail for pledge ID
  FleetyardsSyncHandler-->>useBuybackDetailsSync: return detail-page response
  useBuybackDetailsSync->>RSIBuybackDetailParser: parse response HTML
  RSIBuybackDetailParser-->>useBuybackDetailsSync: return price and insurance details
  useBuybackDetailsSync->>HangarBuybacksController: submit detail records
  HangarBuybacksController->>StoreDetails: update matching user pledges
Loading
🚥 Pre-merge checks | ✅ 4
✅ 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 summarizes the main user-facing changes: buy-back links, pricing, insurance, and availability. It is concise and directly related to the changeset, although it does not mention the s…
Linked Issues check ✅ Passed Issue #5471 is active and has coding requirements. The PR adds RSI links in a new tab for available rows, uses the list page for upgrades, removes the link for unavailable rows, and adds the unavailab…
Out of Scope Changes check ✅ Passed The changes remain within issue #5471. Backend schemas, storage, synchronization endpoints, frontend detail parsing, modal progress, row display, translations, tests, migration, and RSI documentation …
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

mortik added 9 commits October 7, 2026 16:38
…wers and gone pledges

A late answer to a timed-out request no longer answers the next one, a
pledge page without a price or one RSI answers 404 for is stored rather
than read on every sync, and a failed submit or a cancelled pass keeps
what was read.
…rest once

An upgrade costs what the upgrade costs today, the difference between
both ships' store prices, so it is computed on read instead of asking
RSI. Every other buy-back costs what it was bought for, so its details
are read once and never refreshed.
A price in the account's currency with tax included cannot be compared
with anything else FleetYards shows, so only RSI's own USD figure is
stored and served.
A buy-back page prices in the account's currency with tax. The detail
pass reads the account's store pricing first and divides the page price
back into USD, refusing any page whose currency the pricing does not
cover.
@mortik
mortik enabled auto-merge October 7, 2026 15:19

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

Devin Review

Comment on lines +70 to +71
# Missing from a list read before availability was, so it says nothing.
available: ActiveModel::Type::Boolean.new.cast(item[:available]) != false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Older extensions restore unavailable buy-back links

When an older extension omits available, Sync#row replaces a stored false with true. The pledge then shows a buy-back link despite its last known unavailable status.

Learn more

The list sync uses upsert_all to overwrite fields supplied in each snapshot, while older extensions do not send available. The newly added fallback turns that absence into true, even for a pledge already stored as unavailable. The next sync thus reverses a known unavailable state without any new availability evidence.

Example: A newer extension stores pledge 8 with available: false. The user later syncs with an older extension whose pledge 8 has no available field; the stored pledge changes to available and the UI displays its RSI buy-back action.

Recommended fix: Preserve the existing value when available is omitted from a snapshot. Default to true only for new rows without a stored value, while still accepting explicit false and true.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. Fixed in a follow-up commit: a row without available is upserted separately without that column, so a stored false stays and a new pledge takes the column default. The push was refused because this PR is already in the merge queue, so it lands as its own PR. 🤖

Comment on lines +20 to +25
user.buyback_pledges.where(rsi_pledge_id: item[:id].to_s).update_all(
price: item[:price],
insurance_months: item[:insurance_months],
lifetime_insurance: ActiveModel::Type::Boolean.new.cast(item[:lifetime_insurance]) || false,
details_synced_at: synced_at,
updated_at: synced_at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Partial detail updates erase stored prices

When a detail request omits price, StoreDetails#run clears the stored price and marks details complete. Future list syncs skip the pledge, leaving its price missing.

Learn more

The detail input requires only id, so callers can update insurance without sending price. This update writes nil for omitted fields and always sets details_synced_at. The list sync's details_pending only returns rows whose timestamp is nil, so an erased price is never retrieved again by the normal detail pass.

Example: Pledge 1 has a stored price of $100 and six months of insurance. A later detail request {id: "1", insuranceMonths: 120} clears the price, and the next list sync excludes pledge 1 from detailsPending.

Recommended fix: Distinguish omitted fields from explicit clearing in StoreDetails#run, updating only keys present in each item. Define a separate explicit mechanism for recording a 404 or a page with no readable price, and do not mark incomplete updates as fully synced.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intended. A detail post is always one complete read of a pledge's RSI page, and details are read once per pledge (a buy-back costs its original price). An item without price means the page had no readable price, or RSI answered 404, and is recorded as read so that pledge isn't fetched on every sync. Nothing else posts to this endpoint, so there is no partial update that could clear a stored price. 🤖

Comment on lines +7 to +10
t.decimal :price, precision: 15, scale: 2
t.integer :insurance_months
t.boolean :lifetime_insurance, null: false, default: false
t.datetime :details_synced_at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Described currency column is absent

The PR description lists price_currency, but the migration adds no such column. The implementation stores USD without one; reconcile the description with the intended schema.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right, the description was left over from before prices became USD-only. Updated: the columns are available, price (USD), insurance_months, lifetime_insurance and details_synced_at. 🤖

Comment on lines +82 to +87
const message = await request(
FleetyardsSyncAction.SYNC_BUYBACK_DETAIL,
{ id },
undefined,
(reply) => reply.id === id,
).catch(() => undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Verify detail reply IDs before release

The detail pass matches replies by top-level id. Verify the forthcoming extension echoes that field, including on 404 responses; integration was not tested end to end.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checked against the merged extension (fleetyards/sync#164): the detail reply is {action, id, ...result} for every HTTP status, 404 included, and the 401 reply carries id too. Only the 400 for an invalid id omits it, and the page never sends one. 🤖

@mortik
mortik added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 652fff2 Oct 7, 2026
19 checks passed
@mortik
mortik deleted the feat/5471-buyback-details branch October 7, 2026 16:00
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.

Buy-back page: links, price, insurance and availability

1 participant