Skip to content

Commit 7b2b94d

Browse files
committed
feat: add laposte and expiditor etiquette
1 parent 1aaf095 commit 7b2b94d

18 files changed

Lines changed: 1042 additions & 111 deletions

api/EBAY.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ In the developer portal, configure OAuth:
2020

2121
**Important:** no mismatch in trailing slash, `http` vs `https`, or host (`localhost` vs `127.0.0.1`) between the eBay portal, `EBAY_REDIRECT_URI`, and the URL actually opened in the browser.
2222

23+
### Local dev / desktop (Tauri) — “j’ai l’impression de passer sur une vieille version”
24+
25+
After you approve eBay, **the browser always lands on `EBAY_REDIRECT_URI`**, not on whatever tab you started from.
26+
27+
If `EBAY_REDIRECT_URI` points to **production** (e.g. `https://goupixdex.example/settings/marketplaces`) but you started OAuth from **`http://localhost:3000`** or a **Tauri dev** window, you will be redirected to **that production URL**. You then load the **deployed** front-end: no Nuxt devtools, no floating “Redémarrer workers / Sync DB” (those only exist when `import.meta.dev` is true), and the UI may lag behind your local branch.
28+
29+
**Fix:** for local work, register and set `EBAY_REDIRECT_URI` to the **same origin** as the app you are running (e.g. `http://localhost:3000/settings/marketplaces` or your Tauri dev URL if eBay allows it). Use a separate eBay keyset/sandbox RuName if needed.
30+
2331
## 3. Scopes used by GoupixDex
2432

2533
The API requests these scopes (already set in the backend):
@@ -65,6 +73,7 @@ Fulfillment shipping options (multiple domestic rates, international, handling t
6573

6674
## 8. Quick troubleshooting
6775

76+
- **Après « Se connecter à eBay », l’app ressemble à une vieille version / plus d’outils dev** : eBay vous renvoie toujours vers `EBAY_REDIRECT_URI`. Si cette URL est la **production** alors que vous étiez en **localhost** ou Tauri dev, vous chargez le site déployé (build sans mode dev). Alignez `EBAY_REDIRECT_URI` + RuName eBay sur l’origine où vous travaillez — voir §2 ci-dessus.
6877
- **Token exchange error**: `redirect_uri` does not match what is registered at eBay, or `code` already used / expired (codes are single-use and short-lived).
6978
- **“User is not eligible for Business Policy”** (logs: error 20403 on `fulfillment_policy`): the account must be enrolled in **business policies**. The API calls [`optInToProgram`](https://developer.ebay.com/api-docs/sell/account/resources/program/methods/optInToProgram) with `SELLING_POLICY_MANAGEMENT` before loading policies; eBay can take **up to ~24 h** to activate — retry later or check with `getOptedInPrograms`.
7079
- **Publishing error**: wrong category, policies incompatible with the marketplace, or **condition descriptors** required for some card categories — see API logs (`ebay_body`).
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Return / sender address for envelope flap labels (per user).
2+
ALTER TABLE settings ADD COLUMN IF NOT EXISTS sender_full_name VARCHAR(120) NULL;
3+
ALTER TABLE settings ADD COLUMN IF NOT EXISTS sender_line1 VARCHAR(180) NULL;
4+
ALTER TABLE settings ADD COLUMN IF NOT EXISTS sender_line2 VARCHAR(180) NULL;
5+
ALTER TABLE settings ADD COLUMN IF NOT EXISTS sender_postal_code VARCHAR(20) NULL;
6+
ALTER TABLE settings ADD COLUMN IF NOT EXISTS sender_city VARCHAR(80) NULL;

api/models/margin_settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,11 @@ class MarginSettings(Base):
2424
ebay_fulfillment_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
2525
ebay_payment_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
2626
ebay_return_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
27+
#: Envelope flap (return) address — used when printing shipping label sheets.
28+
sender_full_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
29+
sender_line1: Mapped[str | None] = mapped_column(String(180), nullable=True)
30+
sender_line2: Mapped[str | None] = mapped_column(String(180), nullable=True)
31+
sender_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
32+
sender_city: Mapped[str | None] = mapped_column(String(80), nullable=True)
2733

2834
user: Mapped["User"] = relationship(back_populates="margin_settings")

api/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vinted-scraper>=3.0.0
3030

3131
# Shipping label PDF generation (Avery L7173 — 8 labels per A4)
3232
reportlab>=4.0.0
33+
pymupdf>=1.24.0
3334

3435
# Cardmarket order PDF text extraction
3536
pypdf>=5.0.0

api/routes/ebay_route.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,18 @@ def ebay_authorize_url(
8383
force_login: Annotated[bool, Query()] = False,
8484
) -> dict[str, str]:
8585
"""Build the browser URL for eBay consent (User must open it)."""
86+
app = get_settings()
8687
try:
87-
url = build_authorization_url(state=state, force_login=force_login)
88+
url = build_authorization_url(state=state, force_login=force_login, app=app)
8889
except RuntimeError as exc:
8990
if str(exc) == "ebay_oauth_not_configured":
9091
raise HTTPException(
9192
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
9293
detail="eBay OAuth is not configured on the server (EBAY_CLIENT_ID, EBAY_CLIENT_SECRET, EBAY_REDIRECT_URI).",
9394
) from exc
9495
raise HTTPException(status_code=500, detail=str(exc)) from exc
95-
return {"authorization_url": url, "state": state}
96+
redirect = (app.ebay_redirect_uri or "").strip()
97+
return {"authorization_url": url, "state": state, "redirect_uri": redirect}
9698

9799

98100
@router.post("/oauth/exchange", status_code=status.HTTP_200_OK)

api/routes/settings_route.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
from models.user import User
1515
from schemas.settings import SettingsResponse, SettingsUpdate
1616
from services.ebay_oauth_service import ebay_oauth_configured
17-
from services.user_settings_service import ebay_listing_config_complete, get_or_create_user_settings
17+
from services.user_settings_service import (
18+
ebay_listing_config_complete,
19+
get_or_create_user_settings,
20+
sender_address_complete,
21+
)
1822

1923
router = APIRouter(prefix="/settings", tags=["settings"])
2024

@@ -37,6 +41,12 @@ def _to_response(db: Session, user: User) -> SettingsResponse:
3741
ebay_listing_config_complete=ebay_listing_config_complete(s),
3842
ebay_oauth_configured=ebay_oauth_configured(app),
3943
ebay_environment="sandbox" if app.ebay_use_sandbox else "production",
44+
sender_full_name=(s.sender_full_name or "").strip() or None,
45+
sender_line1=(s.sender_line1 or "").strip() or None,
46+
sender_line2=(s.sender_line2 or "").strip() or None,
47+
sender_postal_code=(s.sender_postal_code or "").strip() or None,
48+
sender_city=(s.sender_city or "").strip() or None,
49+
sender_address_complete=sender_address_complete(s),
4050
)
4151

4252

@@ -82,6 +92,19 @@ def put_margin_settings(
8292
setattr(s, key, None)
8393
else:
8494
setattr(s, key, str(val).strip())
95+
for key in (
96+
"sender_full_name",
97+
"sender_line1",
98+
"sender_line2",
99+
"sender_postal_code",
100+
"sender_city",
101+
):
102+
if key in data:
103+
val = data[key]
104+
if val is None or (isinstance(val, str) and not val.strip()):
105+
setattr(s, key, None)
106+
else:
107+
setattr(s, key, str(val).strip())
85108
db.commit()
86109
db.refresh(s)
87110
return _to_response(db, user)

api/routes/shipping_route.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
from services.ebay_orders_service import list_unshipped_orders
1818
from services.ebay_publish_service import ensure_ebay_access_token
1919
from services.shipping_label_service import LabelAddress, render_labels_pdf
20-
from services.user_settings_service import get_or_create_user_settings
20+
from services.stamp_overlay_service import decode_stamp_pdf_base64, overlay_stamps_on_labels_pdf
21+
from services.user_settings_service import get_or_create_user_settings, sender_address_complete
2122

2223
router = APIRouter(prefix="/shipping", tags=["shipping"])
2324

2425

26+
_STAMP_B64_MAX_LEN = 6_000_000
27+
28+
2529
class ShippingLabelInput(BaseModel):
2630
"""One recipient block. Edits made before printing never touch the eBay order."""
2731

@@ -32,6 +36,11 @@ class ShippingLabelInput(BaseModel):
3236
city: str = Field(..., min_length=1, max_length=80)
3337
state: str | None = Field(default=None, max_length=80)
3438
country_code: str | None = Field(default="FR", max_length=3)
39+
stamp_pdf_base64: str | None = Field(
40+
default=None,
41+
max_length=_STAMP_B64_MAX_LEN,
42+
description="Optional La Poste stamp PDF (single-page), base64 or data URL.",
43+
)
3544

3645

3746
class ShippingLabelsBody(BaseModel):
@@ -84,27 +93,71 @@ async def shipping_ebay_orders(
8493
@router.post("/labels.pdf")
8594
async def shipping_labels_pdf(
8695
body: ShippingLabelsBody,
96+
db: Annotated[Session, Depends(get_db)],
8797
user: Annotated[User, Depends(get_current_user)], # noqa: ARG001 (auth gate)
8898
) -> Response:
8999
"""
90100
Render the supplied recipient blocks as an A4 PDF (Avery L7173 — 99×57 mm, 8 per page).
91101
92-
Same endpoint serves both the in-app preview (iframe) and the final download — guarantees
93-
the on-screen rendering matches the printed output byte-for-byte.
102+
Each parcel uses **two** L7173 vignettes stacked vertically on the sheet (sender immediately below recipient).
103+
The sender vignette uses a crop rectangle nearly full sticker width; address stays on **one line** under the name when
104+
possible (font scales down, then ellipsis). Padding inside the crop marks is the same on top, bottom, left, and right.
105+
106+
Optional ``stamp_pdf_base64`` per row (PDF from laposte.fr): artwork on page 1 is overlaid at **native scale**
107+
(no shrinking). Placement: **below** that parcel's sender vignette when the PDF grid leaves white space there (same
108+
column); otherwise **flush-right** on the page, centred vertically on that parcel's recipient+sender pair; if neither
109+
fits, an extra A4 page is appended for that stamp.
94110
"""
95-
addresses = [
96-
LabelAddress(
97-
full_name=row.full_name.strip(),
98-
line1=row.line1.strip(),
99-
line2=row.line2.strip() if row.line2 else None,
100-
postal_code=row.postal_code.strip(),
101-
city=row.city.strip(),
102-
state=row.state.strip() if row.state else None,
103-
country_code=(row.country_code or "FR").strip().upper() or "FR",
111+
ms = get_or_create_user_settings(db, user.id)
112+
if not sender_address_complete(ms):
113+
raise HTTPException(
114+
status_code=status.HTTP_400_BAD_REQUEST,
115+
detail={
116+
"code": "sender_address_incomplete",
117+
"message": (
118+
"Configurez votre adresse expéditeur dans Paramètres → Configuration "
119+
"avant de générer les étiquettes."
120+
),
121+
},
104122
)
105-
for row in body.addresses
106-
]
107-
pdf = render_labels_pdf(addresses)
123+
124+
stamps_by_parcel: list[bytes | None] = []
125+
addresses: list[LabelAddress] = []
126+
for idx, row in enumerate(body.addresses):
127+
try:
128+
stamp_bytes = decode_stamp_pdf_base64(row.stamp_pdf_base64)
129+
except ValueError as exc:
130+
raise HTTPException(
131+
status_code=status.HTTP_400_BAD_REQUEST,
132+
detail={
133+
"code": "invalid_stamp_pdf",
134+
"parcel_index": idx,
135+
"message": str(exc),
136+
},
137+
) from exc
138+
stamps_by_parcel.append(stamp_bytes)
139+
addresses.append(
140+
LabelAddress(
141+
full_name=row.full_name.strip(),
142+
line1=row.line1.strip(),
143+
line2=row.line2.strip() if row.line2 else None,
144+
postal_code=row.postal_code.strip(),
145+
city=row.city.strip(),
146+
state=row.state.strip() if row.state else None,
147+
country_code=(row.country_code or "FR").strip().upper() or "FR",
148+
)
149+
)
150+
sender = LabelAddress(
151+
full_name=(ms.sender_full_name or "").strip(),
152+
line1=(ms.sender_line1 or "").strip(),
153+
line2=(ms.sender_line2 or "").strip() or None,
154+
postal_code=(ms.sender_postal_code or "").strip(),
155+
city=(ms.sender_city or "").strip(),
156+
state=None,
157+
country_code="FR",
158+
)
159+
pdf = render_labels_pdf(addresses, sender=sender)
160+
pdf = overlay_stamps_on_labels_pdf(pdf, stamps_by_parcel)
108161
return Response(
109162
content=pdf,
110163
media_type="application/pdf",

api/schemas/settings.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ class SettingsResponse(BaseModel):
1717
ebay_listing_config_complete: bool
1818
ebay_oauth_configured: bool
1919
ebay_environment: str
20+
sender_full_name: str | None
21+
sender_line1: str | None
22+
sender_line2: str | None
23+
sender_postal_code: str | None
24+
sender_city: str | None
25+
sender_address_complete: bool
2026

2127

2228
class SettingsUpdate(BaseModel):
@@ -29,3 +35,8 @@ class SettingsUpdate(BaseModel):
2935
ebay_fulfillment_policy_id: str | None = Field(default=None, max_length=32)
3036
ebay_payment_policy_id: str | None = Field(default=None, max_length=32)
3137
ebay_return_policy_id: str | None = Field(default=None, max_length=32)
38+
sender_full_name: str | None = Field(default=None, max_length=120)
39+
sender_line1: str | None = Field(default=None, max_length=180)
40+
sender_line2: str | None = Field(default=None, max_length=180)
41+
sender_postal_code: str | None = Field(default=None, max_length=20)
42+
sender_city: str | None = Field(default=None, max_length=80)

0 commit comments

Comments
 (0)