Compare commits

..

16 commits

Author SHA1 Message Date
Ashwin Kumar Sivakumar
38bd8a537d fix(contracts): GET /marketplace route collision crash-looping every profession service
All checks were successful
build-and-release / build (developers) (push) Successful in 1m41s
build-and-release / build (employees) (push) Successful in 1m54s
build-and-release / build (customers) (push) Successful in 2m9s
build-and-release / build (cron) (push) Successful in 2m24s
build-and-release / build (companies) (push) Successful in 2m34s
build-and-release / build (catering-services) (push) Successful in 2m48s
build-and-release / build (gateway) (push) Successful in 1m5s
build-and-release / build (fitness-trainers) (push) Successful in 2m9s
build-and-release / build (graphic-designers) (push) Successful in 2m0s
build-and-release / build (jobs) (push) Successful in 1m52s
build-and-release / build (job-seekers) (push) Successful in 2m24s
build-and-release / build (makeup-artists) (push) Successful in 2m44s
build-and-release / build (photographers) (push) Successful in 1m49s
build-and-release / build (payments) (push) Successful in 3m26s
build-and-release / build-db-migrate (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 5s
build-and-release / build (tutors) (push) Successful in 2m27s
build-and-release / build (social-media-managers) (push) Successful in 2m46s
build-and-release / build (ugc-content-creators) (push) Successful in 2m57s
build-and-release / build (video-editors) (push) Successful in 2m27s
build-and-release / build (users) (push) Successful in 5m5s
Earlier this session's urgent-leads feature (024fa05) added a second
GET /marketplace route to shared_routes() for browsing open leads,
without noticing an existing GET /marketplace route already there for
browsing professional profiles (Redis-cached, ProfessionalRepository::
get_marketplace) -- two genuinely different features that both reached
for the same path.

axum panics on duplicate method+path registration at router-BUILD time,
not request time, so this sat completely latent from deploy until the
next process restart -- which just happened moments ago while cutting
over to CloudNativePG, crash-looping every profession service
(catering-services, developers, fitness-trainers, graphic-designers,
makeup-artists, photographers, social-media-managers, tutors,
ugc-content-creators, video-editors) simultaneously, live.

Moved the open-leads feed to /leads/open, alongside this profession's
other /leads/* endpoints (/leads/request, /leads/requests/me, /leads/
accepted/me) -- a better fit for that route anyway, not just a
collision workaround.
2026-08-18 06:14:18 +05:30
Ashwin Kumar Sivakumar
6defd52016 fix(ai-credits): whole subscription-lifecycle subsystem was non-functional
All checks were successful
build-and-release / build (cron) (push) Successful in 1m10s
build-and-release / build (catering-services) (push) Successful in 1m36s
build-and-release / build (companies) (push) Successful in 2m1s
build-and-release / build (customers) (push) Successful in 2m18s
build-and-release / build (employees) (push) Successful in 2m37s
build-and-release / build (developers) (push) Successful in 2m40s
build-and-release / build (gateway) (push) Successful in 1m19s
build-and-release / build (graphic-designers) (push) Successful in 1m59s
build-and-release / build (fitness-trainers) (push) Successful in 3m2s
build-and-release / build (jobs) (push) Successful in 1m53s
build-and-release / build (job-seekers) (push) Successful in 2m20s
build-and-release / build (makeup-artists) (push) Successful in 2m42s
build-and-release / build (social-media-managers) (push) Successful in 1m49s
build-and-release / build (payments) (push) Successful in 3m21s
build-and-release / build-db-migrate (push) Successful in 9s
build-and-release / build (photographers) (push) Successful in 2m38s
backend-integration-tests / ai-credits (push) Successful in 47s
build-and-release / build (tutors) (push) Successful in 2m51s
build-and-release / build (ugc-content-creators) (push) Successful in 2m55s
build-and-release / build (video-editors) (push) Successful in 2m43s
build-and-release / build (users) (push) Successful in 4m42s
Following up on the job-expiry 'companies' typo found in the same log
sweep: apps/users/src/ai_subscription.rs (upgrade_plan,
schedule_downgrade, apply_scheduled_downgrades, cancel_subscription,
start_trial, expire_trials, get_subscription_history) and the matching
cron tasks were written entirely against schema that was never
migrated:
  - user_ai_subscriptions.downgrade_scheduled_to/is_trial/trial_days/
    trial_ends_at don't exist
  - ai_subscription_history doesn't exist as a table at all, despite
    6 separate INSERT/UPDATE/SELECT call sites against it

Confirmed live: apply_scheduled_downgrades and expire_trials have been
failing on every hourly cron run. Any user hitting upgrade/downgrade/
cancel/trial-start would 500 the same way.

Schema reconstructed from every call site's actual INSERT/UPDATE/SELECT,
not guessed: from_plan_id nullable (LEFT JOIN'd, never NOT NULL in any
insert), to_plan_id NOT NULL (always bound, INNER JOIN'd),
proration_credits/proration_days_remaining default (only upgrade_plan
binds them), created_by nullable (expire_trials never binds it).

Also fixed a real, unrelated bug found in the same cron-log sweep:
apps/cron/src/tasks/jobs.rs's expire_stale_jobs referenced a
nonexistent 'companies' table (real name: company_profiles) -- also
failing every hourly run, meaning LIVE jobs past expiry were never
being flipped to EXPIRED.

Verified against a live Postgres by calling the actual production
functions (not simulated SQL) end-to-end: upgrade with proration +
history, schedule-then-real-cron-applied downgrade, cancellation,
trial-start-then-real-cron-expiry, and the joined history read-back.
All 5 passed.
2026-08-18 04:53:30 +05:30
Ashwin Kumar Sivakumar
e7c1edac0d fix(cron): job-expiry task referenced a nonexistent 'companies' table
All checks were successful
build-and-release / build (companies) (push) Successful in 17s
build-and-release / build (catering-services) (push) Successful in 19s
build-and-release / build (customers) (push) Successful in 18s
build-and-release / build (developers) (push) Successful in 23s
build-and-release / build (employees) (push) Successful in 7s
build-and-release / build (fitness-trainers) (push) Successful in 8s
build-and-release / build (graphic-designers) (push) Successful in 6s
build-and-release / build (job-seekers) (push) Successful in 7s
build-and-release / build (gateway) (push) Successful in 11s
build-and-release / build (jobs) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 7s
build-and-release / build (payments) (push) Successful in 10s
build-and-release / build (photographers) (push) Successful in 6s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (ugc-content-creators) (push) Successful in 8s
build-and-release / build (users) (push) Successful in 8s
build-and-release / build (tutors) (push) Successful in 12s
build-and-release / build (video-editors) (push) Successful in 6s
build-and-release / build-db-migrate (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 10s
build-and-release / build (cron) (push) Successful in 1m15s
Found while checking cron logs after today's deploy: expire_stale_jobs
has been failing every single hourly run ("relation 'companies' does
not exist") -- the real table is company_profiles, same name used
correctly everywhere else in this codebase. Not something this
session introduced; just the first time anyone looked at these logs
closely enough to notice. Net effect: LIVE jobs past their expires_at
have never actually been flipped to EXPIRED, and no expiry email ever
went out.
2026-08-18 04:49:16 +05:30
Ashwin Kumar Sivakumar
341322e0ee feat(cron): 7-day accepted-lead expiry, 3-month purchased-Tracecoin expiry
All checks were successful
build-and-release / build (companies) (push) Successful in 2m14s
build-and-release / build (customers) (push) Successful in 2m17s
build-and-release / build (developers) (push) Successful in 2m25s
build-and-release / build (cron) (push) Successful in 2m36s
build-and-release / build (catering-services) (push) Successful in 2m43s
build-and-release / build (gateway) (push) Successful in 33s
build-and-release / build (jobs) (push) Successful in 39s
build-and-release / build (employees) (push) Successful in 1m41s
build-and-release / build (graphic-designers) (push) Successful in 1m51s
build-and-release / build (fitness-trainers) (push) Successful in 2m17s
build-and-release / build (makeup-artists) (push) Successful in 1m54s
build-and-release / build (photographers) (push) Successful in 1m53s
build-and-release / build (job-seekers) (push) Successful in 3m29s
build-and-release / build (payments) (push) Successful in 3m0s
build-and-release / build-db-migrate (push) Successful in 10s
build-and-release / build (tutors) (push) Successful in 2m32s
backend-integration-tests / ai-credits (push) Successful in 44s
build-and-release / build (social-media-managers) (push) Successful in 3m4s
build-and-release / build (ugc-content-creators) (push) Successful in 2m59s
build-and-release / build (video-editors) (push) Successful in 2m37s
build-and-release / build (users) (push) Successful in 4m40s
Two checklist rules that had zero implementation until now (confirmed
by the earlier audit this session -- no code, no schema for either):

1. Accepted leads expire 7 days after acceptance
   (apps/cron/src/tasks/lead_requests.rs::expire_accepted_leads,
   hourly). No wallet/ledger involvement -- by ACCEPTED, the
   professional's Tracecoins were already permanently debited from
   `reserved` at acceptance time, so expiry is purely a status/
   visibility change marking the engagement window closed, not a
   financial reversal. Guarded on status = 'ACCEPTED' so it can't
   double-process or race an in-flight customer/professional action on
   the same lead_request.

2. Purchased Tracecoins expire 3 months after purchase
   (apps/cron/src/tasks/tracecoin_expiry.rs::expire_purchased_
   tracecoin_buckets, daily). This is the bigger piece:
   - TracecoinWalletRepository::create_purchase_bucket records each
     real purchase as a bucket in the already-existing but previously
     unused tracecoin_buckets table, with a 90-day expiry. Wired into
     apps/payments/src/reconcile.rs::finalize_successful_payment, the
     only real Tracecoin purchase path in the codebase (idempotent on
     payment id via the table's existing unique index -- a retried
     finalize can't create two buckets).
   - TracecoinWalletRepository::consume_purchase_buckets_fifo draws
     buckets down oldest-expiring-first whenever a permanent spend
     actually happens -- wired into try_debit_reserved_tracecoins
     (lead acceptance) and try_debit_balance (the urgent-lead
     upgrade). NOT wired into reserve/release, since no money has
     left the wallet yet at that point. NOT wired into admin_adjust --
     "purchased credits", not all credits; admin grants don't create
     buckets and so can't be clawed back by this either.
   - The daily sweep claws back only a bucket's *unspent* remainder
     once it's past expiry, locked per-wallet (FOR UPDATE) so it can't
     race a concurrent spend, bounded to never drive balance negative,
     and writes a real tracecoin_ledger DEBIT/TRACECOIN_EXPIRY entry.
     No email is sent -- the one plausible template
     (send_lead_expired_email, "lead-expired") says "tracecoins
     returned", which would misstate what happened; sending a wrong-
     context email is worse than sending none. In-app notification
     only, for now.

Verified against a live Postgres (not just compiled), including two
things that would be very easy to get subtly wrong:
  - FIFO ordering: seeded a sooner-expiring small bucket and a
    later-expiring large one, spent an amount spanning both, confirmed
    the soonest-expiring bucket drains completely before the second
    one is touched at all.
  - The expiry sweep run against three buckets in different states
    (fully spent, expired-with-leftover, not-yet-expired) confirms it
    claws back only the expired+unspent one, leaves the other two
    untouched, and is idempotent on immediate re-run.
  - The 7-day lead expiry only fires on ACCEPTED leads whose
    resolved_at is actually past the cutoff, leaving a recently-
    accepted lead and an unrelated still-PENDING request alone.

cargo test -p db (real integration suite, live DB) still green: 8/8.
2026-08-18 02:11:08 +05:30
Ashwin Kumar Sivakumar
c5b3a9583d fix(users): registration was broken -- users.first_name/last_name never existed
All checks were successful
build-and-release / build (cron) (push) Successful in 1m2s
build-and-release / build (catering-services) (push) Successful in 1m36s
build-and-release / build (companies) (push) Successful in 2m11s
build-and-release / build (employees) (push) Successful in 2m25s
build-and-release / build (developers) (push) Successful in 2m47s
build-and-release / build (customers) (push) Successful in 3m5s
build-and-release / build (gateway) (push) Successful in 1m46s
build-and-release / build (fitness-trainers) (push) Successful in 2m44s
build-and-release / build (graphic-designers) (push) Successful in 2m0s
build-and-release / build (jobs) (push) Successful in 1m52s
build-and-release / build (makeup-artists) (push) Successful in 1m40s
build-and-release / build (job-seekers) (push) Successful in 3m1s
build-and-release / build (social-media-managers) (push) Successful in 1m54s
build-and-release / build (payments) (push) Successful in 2m57s
build-and-release / build (photographers) (push) Successful in 2m57s
build-and-release / build (tutors) (push) Successful in 2m43s
backend-integration-tests / ai-credits (push) Successful in 1m9s
build-and-release / build (ugc-content-creators) (push) Successful in 3m12s
build-and-release / build-db-migrate (push) Successful in 2m23s
build-and-release / build (video-editors) (push) Successful in 2m46s
build-and-release / build (users) (push) Successful in 4m56s
Root cause, traced via git history: commit a3076ed ("update DB schema -
split users.first_name, users.last_name, roles split", 2026-04-15)
rewrote ~35 files across the codebase (UserRepository, and read sites
throughout apps/users, apps/companies, apps/job_seekers,
apps/customers, crates/contracts) to use users.first_name/last_name
instead of users.full_name -- but never added the migration to
actually create those columns. UserRepository::create is the only
INSERT into users and the live registration path; it's been trying to
insert into nonexistent columns ever since.

Confirmed this independently by spinning up Postgres locally, applying
every migration in crates/db/migrations (the exact set Dockerfile.migrate
bakes into the deployed db-migrate image, so this isn't a
theoretical-config-drift argument -- it's what's actually shipped) with
DROP_EXISTING_TABLES unset against a clean DB, and running the real
UserRepository::create call: `column "first_name" of relation "users"
does not exist`.

Finishes what that commit should have done: adds first_name/last_name
to users, backfilled from the pre-existing full_name (naive split on
first space). Does NOT drop full_name -- apps/payments (invoice/billing
legal names) and job_seeker_profiles still correctly read it, so
UserRepository::create now also derives and writes full_name from
first_name/last_name, keeping both representations correct for every
user going forward instead of only whichever one a given caller reads.

Verified against a real local Postgres, not just compiled:
- All 40 migrations in this directory apply cleanly in sequence, and
  a second and third run are fully idempotent (this surfaced three
  more of my own earlier migrations this session -- job_applications
  unique constraint, company_profiles non-negative checks, lead_requests
  FKs -- that used bare `ADD CONSTRAINT` with no IF NOT EXISTS guard;
  since this repo's db-migrate has no migration-tracking table and
  re-executes every .up.sql on every deploy, those would have errored
  on the second deploy and silently blocked every migration after them
  alphabetically, including all of today's other fixes. Fixed to match
  the DO $$ ... pg_constraint IF NOT EXISTS pattern already used
  elsewhere in this migrations directory.)
- A standalone harness actually calling UserRepository::create against
  that live DB confirms registration succeeds and full_name ends up
  correctly populated.
- The backfill was verified against simulated legacy rows (full_name
  only, both single- and multi-word names).
- cargo test -p db (real integration tests, not just compile-checked --
  requires TEST_DATABASE_URL, which is why these hadn't run all session)
  passes in full: 8 tests including concurrent_reservations_cannot_
  overdraw_wallet and expired_hold_is_swept_and_credits_return_to_available.
2026-08-18 01:58:23 +05:30
Ashwin Kumar Sivakumar
efd2ec6222 fix: wire up dead admin router, IDOR on submit_requirement, dead code cleanup
All checks were successful
build-and-release / build (cron) (push) Successful in 55s
build-and-release / build (catering-services) (push) Successful in 2m7s
build-and-release / build (companies) (push) Successful in 2m14s
build-and-release / build (fitness-trainers) (push) Successful in 1m41s
build-and-release / build (customers) (push) Successful in 2m58s
build-and-release / build (developers) (push) Successful in 2m28s
build-and-release / build (employees) (push) Successful in 2m34s
build-and-release / build (gateway) (push) Successful in 1m52s
build-and-release / build (graphic-designers) (push) Successful in 2m8s
build-and-release / build (jobs) (push) Successful in 1m49s
build-and-release / build (job-seekers) (push) Successful in 2m31s
build-and-release / build (makeup-artists) (push) Successful in 1m52s
build-and-release / build (social-media-managers) (push) Successful in 1m46s
build-and-release / build (photographers) (push) Successful in 2m32s
build-and-release / build (payments) (push) Successful in 3m24s
build-and-release / build (tutors) (push) Successful in 2m38s
backend-integration-tests / ai-credits (push) Successful in 46s
build-and-release / build (ugc-content-creators) (push) Successful in 3m6s
build-and-release / build (video-editors) (push) Successful in 2m37s
build-and-release / build-db-migrate (push) Successful in 2m19s
build-and-release / build (users) (push) Successful in 5m8s
apps/payments/src/admin.rs (tax rules, ledger, orders, credits, invoice
admin endpoints) was written but never actually shipped: no `mod
admin;` in main.rs, no `wallet` crate dependency in Cargo.toml, and its
routes used axum 0.6-style `:id` path syntax (this workspace is on
0.8, which requires `{id}` and panics on `:id` at router-build time).
Meanwhile apps/gateway explicitly forwards /api/admin/invoices*,
/api/admin/tax, /api/admin/orders, /api/admin/ledger, and
/api/admin/credits/* to this service, so every one of those admin
endpoints has been 404ing in production. Fixed all three issues and
merged the router in. Also fixed three inline SQL queries
(list_ledger x2, get_credit_ledger) still selecting the nonexistent
`type`/`reason` columns this session's earlier wallet-crate fix
already corrected everywhere else -- these went unnoticed until now
because dynamic SQL isn't checked at compile time, and the file had
literally never been compiled before.

apps/customers/src/handlers.rs submit_requirement had no ownership
check -- every sibling handler on the same resource (get_requirement,
update_requirement, mark_requirement_urgent) checks
req.created_by_user_id against auth.user_id; this one didn't, so any
authenticated customer could submit another customer's DRAFT
requirement into the approval queue by id, with the verification case
and email misattributed to the caller instead of the actual owner.

Removed ProfessionalRepository::try_reserve_tracecoins/
try_debit_reserved_tracecoins/try_release_reserved_tracecoins
(crates/db/src/models/professional.rs) -- a dead, unreferenced
duplicate of TracecoinWalletRepository's already-correct
reserve/debit/release, whose ledger INSERTs used the same nonexistent
`type`/`reason` columns and would have errored at runtime if anything
had ever called it. A second implementation of the same money-moving
logic is itself the risk, so removed rather than fixed in place.

Added the missing FK constraints on lead_requests.lead_id and
.user_role_profile_id (only professional_user_id/customer_user_id had
one) -- app code always populates them correctly today, but nothing
DB-side stopped a future bug from writing a dangling reference.

Centralized the 18% GST rate (invoice::STANDARD_GST_RATE_PERCENT)
instead of the literal 18.0 duplicated at both invoice-generation call
sites.

Not fixed, flagged instead: the admin reconcile_credits report
compares each wallet's all-time balance against a ledger sum restricted
to a 30-day-by-default window -- objectively wrong, but the deeper
problem is that tracecoin_ledger.amount's sign convention differs
between writers (TracecoinWalletRepository stores unsigned magnitudes
keyed by transaction_type; crates/wallet stores signed balance deltas
directly), so a correct "balance == sum(ledger)" reconciliation needs
that inconsistency resolved first -- not a quick fix, a design
decision on the write side.
2026-08-18 01:44:10 +05:30
Ashwin Kumar Sivakumar
024fa05e96 feat(marketplace): urgent/featured leads for customers, correct request cap
Some checks failed
build-and-release / build (customers) (push) Successful in 1m57s
build-and-release / build (employees) (push) Successful in 2m5s
build-and-release / build (catering-services) (push) Successful in 2m32s
build-and-release / build (companies) (push) Successful in 2m38s
build-and-release / build (cron) (push) Successful in 2m54s
build-and-release / build (gateway) (push) Successful in 1m26s
build-and-release / build (fitness-trainers) (push) Successful in 1m46s
build-and-release / build (developers) (push) Successful in 3m58s
build-and-release / build (jobs) (push) Successful in 1m12s
build-and-release / build (graphic-designers) (push) Successful in 2m30s
build-and-release / build (makeup-artists) (push) Successful in 1m43s
build-and-release / build (job-seekers) (push) Successful in 3m1s
build-and-release / build (payments) (push) Successful in 2m51s
build-and-release / build (social-media-managers) (push) Successful in 2m33s
build-and-release / build-db-migrate (push) Successful in 7s
build-and-release / build (photographers) (push) Successful in 3m1s
backend-integration-tests / ai-credits (push) Successful in 50s
build-and-release / build (tutors) (push) Successful in 2m51s
build-and-release / build (ugc-content-creators) (push) Successful in 2m42s
build-and-release / build (video-editors) (push) Successful in 2m33s
build-and-release / build (users) (push) Has been cancelled
New paid feature per product clarification: a customer can pay 50
Tracecoins to mark their own open requirement ("lead", professional-
facing) urgent. Urgent leads:
  - sort first in the professional-facing open-leads feed (new --
    there was previously no way for a professional to discover a
    requirement other than being handed its id directly, so this adds
    GET /marketplace alongside the existing GET /marketplace/{id})
  - get a raised per-lead request cap: 20 instead of the normal 10
    (was hardcoded to 20 for everyone -- corrected to the actual
    intended normal cap while adding the urgent tier)
  - trigger an immediate best-effort notification to approved
    professionals in the same profession + location

No separate expiry: urgent status rides the existing 7-day
expires_at, it doesn't extend anything -- it only changes ordering,
cap, and notification while the lead is already live.

TracecoinWalletRepository::try_debit_balance is a new one-shot debit
primitive (SELECT...FOR UPDATE, idempotent on reference_type +
reference_id) for spends that don't need reserve/confirm's two-step
hold -- mark_requirement_urgent debits before flagging the lead, so a
failed/insufficient-funds debit leaves no state to compensate, and a
retry (or two concurrent requests for the same lead) can't double-
charge.
2026-08-18 01:34:24 +05:30
Ashwin Kumar Sivakumar
0432ddcb51 fix(wallet): fund-leak on lead rejection, broken expiry refund, dead admin_adjust
All checks were successful
build-and-release / build (catering-services) (push) Successful in 1m53s
build-and-release / build (developers) (push) Successful in 1m56s
build-and-release / build (employees) (push) Successful in 2m14s
build-and-release / build (customers) (push) Successful in 2m32s
build-and-release / build (cron) (push) Successful in 2m42s
build-and-release / build (companies) (push) Successful in 3m36s
build-and-release / build (fitness-trainers) (push) Successful in 1m58s
build-and-release / build (graphic-designers) (push) Successful in 1m53s
build-and-release / build (gateway) (push) Successful in 2m14s
build-and-release / build (jobs) (push) Successful in 1m55s
build-and-release / build (makeup-artists) (push) Successful in 1m54s
build-and-release / build (photographers) (push) Successful in 1m50s
build-and-release / build (social-media-managers) (push) Successful in 2m33s
build-and-release / build (tutors) (push) Successful in 2m43s
build-and-release / build-db-migrate (push) Successful in 15s
build-and-release / build (ugc-content-creators) (push) Successful in 2m34s
backend-integration-tests / ai-credits (push) Successful in 1m6s
build-and-release / build (video-editors) (push) Successful in 2m32s
build-and-release / build (job-seekers) (push) Successful in 7m25s
build-and-release / build (users) (push) Successful in 5m5s
build-and-release / build (payments) (push) Successful in 7m42s
Three independent Tracecoin correctness bugs found auditing the
services-marketplace lead flow:

1. reject_request passed lead.user_role_profile_id -- the wrong id --
   to try_release_reserved_tracecoins, which looks the wallet up by
   user_id. It never found a matching wallet, silently returned
   Ok(false), and the handler returned 409 to the customer -- but
   lead_requests.status had already committed to REJECTED on the
   previous statement. Every rejected lead permanently stranded the
   professional's reserved Tracecoins, with no cron sweeping REJECTED
   leads to recover them. Now uses professional_user_id, matching
   approve_request's (correct) debit call.

2. apps/cron/src/tasks/leads.rs's stale-PENDING-lead refund wrote
   `UPDATE tracecoin_wallets SET current_balance = current_balance +
   $1` -- that column doesn't exist (it's `balance`), so this errored
   on every run that hit a reserved-coins expiry, aborting the whole
   function via `?` before the transaction committed (rolling back the
   EXPIRED status flip with it). Leads with reserved coins never
   actually expired or refunded, silently, every 15 minutes. Fixed the
   column name and made it also decrement `reserved` (previously it
   only ever credited `balance`, never releasing the hold itself).

3. crates/wallet (credit/reserve/release/confirm/admin_adjust) and its
   hold/escrow submodule were built against a tracecoin_ledger schema
   (`type`, `reason`, `balance_after`, `actor_user_id`, `metadata`
   columns, a `lock_tracecoin_wallet()` SQL function) that was never
   actually migrated -- the migration that would have added it was
   disabled (`.up.sql.skip`), and even that skipped file assumed
   column names that don't match this repo's real, separately-evolved
   ledger schema (`transaction_type`/`reference_type`). Every call into
   crates/wallet errored before doing anything. The one live caller is
   the admin manual wallet-adjustment endpoint (apps/payments/src/
   admin.rs), so every admin credit/debit adjustment 500'd. Migration
   adds the function plus the genuinely-new audit columns
   (balance_after/actor_user_id/metadata) and an idempotency index,
   while the code now reads/writes through the existing transaction_
   type/reference_type columns instead of introducing a second,
   competing pair.

Also fixes the underlying check-then-act race on lead_requests.status:
approve_request/reject_request read status, branched in Rust, then
called an unconditional UPDATE with no re-check -- concurrent
approve/reject on the same lead could both pass the in-memory check.
LeadRequestRepository::update_status_from adds a `WHERE status =
$from` guard, used both for the initial PENDING transition and to
compensate (revert to PENDING) if the wallet debit/release fails after
the status flip already committed.

hold::place (the escrow "reserve" half) has zero callers anywhere in
the codebase -- flagging separately, not fixed here, since wiring it
up or removing it is a product decision, not a bug fix.
2026-08-18 00:51:19 +05:30
Ashwin Kumar Sivakumar
617a75971f fix(companies): contact-unlock double-spend and job-quota race
view_contact previously decremented free_contact_views/
purchased_contact_views on every call with no record of which
applications had already had their contact unlocked -- refreshing the
same application's contact panel silently re-spent the allowance every
time, not just under concurrency, and the check-then-act shape also let
concurrent requests both pass the exhausted-quota check before either
UPDATE committed. Now the whole claim (dedup check + conditional
decrement + job_applications.contact_unlocked_at write) runs in one
transaction with company_profiles locked FOR UPDATE, gated on a
WHERE ... > 0 guard on the decrement itself.

create_job had the same shape for the 1-free-job/month rule and
purchased_job_slots: two concurrent requests could both observe
"quota available" before either committed, letting a company publish
2+ free jobs in a month or drive purchased_job_slots negative. Same
fix: lock company_profiles FOR UPDATE for the whole check+mutate+
insert sequence.

Also adds CHECK (>= 0) constraints on all four company_profiles
counters as defense-in-depth, matching the pattern tracecoin_wallets
already uses for balance/reserved.
2026-08-18 00:51:02 +05:30
Ashwin Kumar Sivakumar
cdf78635f6 fix(payments): reconcile PENDING payments that never redirect back from PayU
All checks were successful
build-and-release / build (customers) (push) Successful in 12s
build-and-release / build (catering-services) (push) Successful in 14s
build-and-release / build (developers) (push) Successful in 16s
build-and-release / build (companies) (push) Successful in 20s
build-and-release / build (employees) (push) Successful in 22s
build-and-release / build (cron) (push) Successful in 23s
build-and-release / build (fitness-trainers) (push) Successful in 9s
build-and-release / build (gateway) (push) Successful in 9s
build-and-release / build (graphic-designers) (push) Successful in 10s
build-and-release / build (job-seekers) (push) Successful in 8s
build-and-release / build (makeup-artists) (push) Successful in 7s
build-and-release / build (photographers) (push) Successful in 10s
build-and-release / build (jobs) (push) Successful in 14s
build-and-release / build (social-media-managers) (push) Successful in 12s
build-and-release / build (tutors) (push) Successful in 11s
build-and-release / build (ugc-content-creators) (push) Successful in 8s
build-and-release / build (users) (push) Successful in 10s
backend-integration-tests / ai-credits (push) Successful in 10s
build-and-release / build-db-migrate (push) Successful in 14s
build-and-release / build (video-editors) (push) Successful in 15s
build-and-release / build (payments) (push) Successful in 4m2s
/verify only runs if the client's browser makes it back after PayU
redirects; a payment that succeeded on PayU's side but whose tab was
closed or lost network before redirecting sat at PENDING forever with
no server-side path to notice.

Adds a poll job (apps/payments/src/reconcile.rs) run every 5 minutes
that queries PayU's verify_payment API directly for any payment still
PENDING after 15 minutes (giving up after 7 days -- almost certainly
an abandoned cart by then, not a missed callback), and finalizes or
fails it server-side.

Extracts the actual claim-and-credit transaction (SELECT ... FOR
UPDATE gated on status = 'PENDING') out of verify_payment into
reconcile::finalize_successful_payment, shared by both callers so
there's exactly one implementation of the double-credit-proof claim to
reason about; it's what makes it safe for the poll job to independently
race /verify on the same txnid. Also unifies invoice/notification
side-effects into finish_successful_payment_side_effects, drawing
billing contact from `users` rather than the client-supplied /verify
payload (the poll path has no such payload, and it's the more correct
source of truth for the GST invoice either way).
2026-08-17 23:51:01 +05:30
Ashwin Kumar Sivakumar
f6a23cb99a fix(db): add missing job_applications unique(job_id, applicant_user_id)
All checks were successful
build-and-release / build (cron) (push) Successful in 1m2s
build-and-release / build (customers) (push) Successful in 2m3s
build-and-release / build (developers) (push) Successful in 2m14s
build-and-release / build (employees) (push) Successful in 2m20s
build-and-release / build (catering-services) (push) Successful in 2m44s
build-and-release / build (gateway) (push) Successful in 1m12s
build-and-release / build (fitness-trainers) (push) Successful in 2m29s
build-and-release / build (jobs) (push) Successful in 1m5s
build-and-release / build (companies) (push) Successful in 4m0s
build-and-release / build (graphic-designers) (push) Successful in 2m32s
build-and-release / build (makeup-artists) (push) Successful in 1m46s
build-and-release / build (payments) (push) Successful in 2m43s
build-and-release / build (photographers) (push) Successful in 2m40s
build-and-release / build (tutors) (push) Successful in 2m47s
build-and-release / build (ugc-content-creators) (push) Successful in 2m46s
backend-integration-tests / ai-credits (push) Successful in 46s
build-and-release / build (video-editors) (push) Successful in 2m45s
build-and-release / build (job-seekers) (push) Successful in 8m2s
build-and-release / build-db-migrate (push) Successful in 3m9s
build-and-release / build (social-media-managers) (push) Successful in 7m54s
build-and-release / build (users) (push) Successful in 8m25s
apply_to_job does check-then-insert with no transaction/lock to block
duplicate applications - race-condition-prone. The INSERT error handler
already anticipated a unique-violation ('unique' in error string -> 409
ALREADY_APPLIED) but the constraint itself was never created, so that
branch was dead code. Adding it now gives the existing handling a real
backstop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:42:53 +05:30
Ashwin Kumar Sivakumar
4ee593f407 fix(db): pricing_packages.price_inr was seeded as rupees, treated as paise
All checks were successful
build-and-release / build (companies) (push) Successful in 2m4s
build-and-release / build (customers) (push) Successful in 2m7s
build-and-release / build (employees) (push) Successful in 2m14s
build-and-release / build (catering-services) (push) Successful in 2m34s
build-and-release / build (cron) (push) Successful in 2m40s
build-and-release / build (developers) (push) Successful in 3m16s
build-and-release / build (gateway) (push) Successful in 1m12s
build-and-release / build (graphic-designers) (push) Successful in 1m44s
build-and-release / build (jobs) (push) Successful in 1m52s
build-and-release / build (fitness-trainers) (push) Successful in 2m38s
build-and-release / build (job-seekers) (push) Successful in 2m25s
build-and-release / build (photographers) (push) Successful in 1m55s
build-and-release / build (makeup-artists) (push) Successful in 2m43s
build-and-release / build (social-media-managers) (push) Successful in 2m56s
build-and-release / build (tutors) (push) Successful in 2m49s
build-and-release / build (payments) (push) Successful in 4m44s
backend-integration-tests / ai-credits (push) Successful in 1m3s
build-and-release / build (video-editors) (push) Successful in 2m55s
build-and-release / build (ugc-content-creators) (push) Successful in 4m26s
build-and-release / build-db-migrate (push) Successful in 3m6s
build-and-release / build (users) (push) Successful in 8m29s
58 of 59 rows were inserted with plain rupee-looking values (499, 999,
1999, ...) but every consumer (apps/payments/src/main.rs) treats
price_inr as paise, so PayU would have charged 1/100th of the intended
price. Confirmed zero rows in payments/orders — pre-launch data fix, not
a refund situation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:23:56 +05:30
Ashwin Kumar Sivakumar
c486f60775 chore: retrigger CI after Forgejo restart
All checks were successful
build-and-release / build (catering-services) (push) Successful in 14s
build-and-release / build (customers) (push) Successful in 12s
build-and-release / build (companies) (push) Successful in 16s
build-and-release / build (developers) (push) Successful in 15s
build-and-release / build (employees) (push) Successful in 5s
build-and-release / build (cron) (push) Successful in 21s
build-and-release / build (fitness-trainers) (push) Successful in 5s
build-and-release / build (gateway) (push) Successful in 5s
build-and-release / build (graphic-designers) (push) Successful in 6s
build-and-release / build (job-seekers) (push) Successful in 6s
build-and-release / build (jobs) (push) Successful in 5s
build-and-release / build (makeup-artists) (push) Successful in 5s
build-and-release / build (payments) (push) Successful in 6s
build-and-release / build (photographers) (push) Successful in 6s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (tutors) (push) Successful in 5s
build-and-release / build (ugc-content-creators) (push) Successful in 6s
build-and-release / build (users) (push) Successful in 6s
build-and-release / build (video-editors) (push) Successful in 5s
build-and-release / build-db-migrate (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 6s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:18:49 +05:30
Ashwin Kumar Sivakumar
4beb380aca chore: trigger CI rebuild after clearing Forgejo disk pressure
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:16:08 +05:30
Ashwin Kumar Sivakumar
e8f4262804 fix(db): drop untracked invoices_payment_id_check trigger before 050000 recreates it
Prod has this trigger from an out-of-band creation that was never recorded
in _sqlx_migrations, so migration 20260814050000 failed with 'trigger
already exists' and stopped the whole migrate run partway through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:37:08 +05:30
Tracewebstudio Dev
92072f04bd fix: testing session bugs (BUG-54 through BUG-56) and API improvements
- BUG-55: Fix coupon min_order_amount display dividing rupees by 100
  (users/handlers/coupons.rs - remove /100 from error message format)
- BUG-56: Fix admin applications endpoint 500 - add reference_number to
  SELECT in list_applications query (companies/handlers/admin.rs)
- BUG-54: KB category requires explicit slug field (documented; by-design)
- Various API handler improvements discovered during end-to-end testing:
  auth, profile, support, reviews, kb, pricing, modules, activity_logs,
  admin, ai_phase4, config handlers in users service
- Job seekers and customers service handler fixes
- Gateway and jobs service improvements
- DB model field additions for professional role types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 14:57:50 +02:00
71 changed files with 2691 additions and 678 deletions

1
Cargo.lock generated
View file

@ -3078,6 +3078,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"wallet",
] ]
[[package]] [[package]]

View file

@ -10,7 +10,7 @@ use axum::{
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -136,10 +136,11 @@ impl From<Application> for AdminApplicationRow {
} }
async fn list_companies( async fn list_companies(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(_q): Query<ListQuery>, Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let companies = sqlx::query_as::<_, CompanyProfile>( let companies = sqlx::query_as::<_, CompanyProfile>(
r#" r#"
SELECT id, user_id, company_name, registration_number, industry, website_url, SELECT id, user_id, company_name, registration_number, industry, website_url,
@ -161,10 +162,11 @@ async fn list_companies(
} }
async fn get_company( async fn get_company(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let company = sqlx::query_as::<_, CompanyProfile>( let company = sqlx::query_as::<_, CompanyProfile>(
r#" r#"
SELECT id, user_id, company_name, registration_number, industry, website_url, SELECT id, user_id, company_name, registration_number, industry, website_url,
@ -187,10 +189,11 @@ async fn get_company(
} }
async fn approve_company( async fn approve_company(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1") sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -200,10 +203,11 @@ async fn approve_company(
} }
async fn reject_company( async fn reject_company(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
sqlx::query("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1") sqlx::query("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -213,10 +217,11 @@ async fn reject_company(
} }
async fn suspend_company( async fn suspend_company(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
sqlx::query("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1") sqlx::query("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -233,11 +238,12 @@ pub struct GrantJobSlotsPayload {
/// Manual top-up for `purchased_job_slots` while the self-serve purchase flow /// Manual top-up for `purchased_job_slots` while the self-serve purchase flow
/// (TraceCoin/PayU) doesn't exist yet. Support-only unblock path. /// (TraceCoin/PayU) doesn't exist yet. Support-only unblock path.
async fn grant_job_slots( async fn grant_job_slots(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<GrantJobSlotsPayload>, Json(payload): Json<GrantJobSlotsPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
if payload.slots <= 0 { if payload.slots <= 0 {
return Err((StatusCode::BAD_REQUEST, "slots must be a positive integer".to_string())); return Err((StatusCode::BAD_REQUEST, "slots must be a positive integer".to_string()));
} }
@ -256,10 +262,11 @@ async fn grant_job_slots(
} }
async fn list_jobs( async fn list_jobs(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(_q): Query<ListQuery>, Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let jobs = sqlx::query_as::<_, Job>( let jobs = sqlx::query_as::<_, Job>(
r#" r#"
SELECT id, company_id, title, category, description, location, job_type, SELECT id, company_id, title, category, description, location, job_type,
@ -288,6 +295,9 @@ async fn approve_job(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query( let result = sqlx::query(
"UPDATE jobs SET status = 'LIVE', approved_at = NOW(), approved_by = $1 WHERE id = $2 AND status = 'PENDING_APPROVAL'" "UPDATE jobs SET status = 'LIVE', approved_at = NOW(), approved_by = $1 WHERE id = $2 AND status = 'PENDING_APPROVAL'"
) )
@ -311,6 +321,9 @@ async fn reject_job(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<RejectJobPayload>, Json(payload): Json<RejectJobPayload>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query( let result = sqlx::query(
"UPDATE jobs SET status = 'REJECTED', rejection_reason = $1, approved_by = $2 WHERE id = $3 AND status = 'PENDING_APPROVAL'" "UPDATE jobs SET status = 'REJECTED', rejection_reason = $1, approved_by = $2 WHERE id = $3 AND status = 'PENDING_APPROVAL'"
) )
@ -330,13 +343,14 @@ async fn reject_job(
} }
async fn list_applications( async fn list_applications(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(_q): Query<ListQuery>, Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let applications = sqlx::query_as::<_, Application>( let applications = sqlx::query_as::<_, Application>(
r#" r#"
SELECT id, job_id, applicant_user_id, cover_note, status, SELECT id, reference_number, job_id, applicant_user_id, cover_note, status,
applied_at, updated_at applied_at, updated_at
FROM job_applications FROM job_applications
ORDER BY applied_at DESC ORDER BY applied_at DESC

View file

@ -199,15 +199,66 @@ async fn create_job(
).into_response(); ).into_response();
} }
// --- New Quota Logic --- // --- Quota logic ---
let jobs_this_month = match JobRepository::count_by_company_id_this_month(&state.pool, company.id).await { // The free-job-per-month count and the purchased-slot decrement both
Ok(count) => count, // ran as independent, unlocked statements against a company_profiles
// row read before either of them -- two concurrent create_job calls for
// the same company could each observe "0 jobs this month" (or "slots
// remaining > 0") before either commits, letting a company publish 2+
// free jobs in a month or drive purchased_job_slots negative. Same
// class of bug as the job_applications duplicate-application race.
//
// Locking company_profiles FOR UPDATE for the whole check+mutate+insert
// sequence serializes concurrent create_job calls for one company: the
// second call blocks until the first commits, then sees the real,
// already-updated counts.
let mut tx = match state.pool.begin().await {
Ok(tx) => tx,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
}; };
if let Err(e) = sqlx::query("SELECT id FROM company_profiles WHERE id = $1 FOR UPDATE")
.bind(company.id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
}
let jobs_this_month: i64 = match sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM jobs
WHERE company_id = $1
AND created_at >= date_trunc('month', now())
AND status != 'REJECTED'
"#,
)
.bind(company.id)
.fetch_one(&mut *tx)
.await
{
Ok(count) => count,
Err(e) => {
let _ = tx.rollback().await;
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
}
};
if jobs_this_month >= 1 { if jobs_this_month >= 1 {
// Must use a purchased slot if they've already used their monthly freebie // Must use a purchased slot if they've already used their monthly freebie
if company.purchased_job_slots <= 0 { let deduct_result = sqlx::query(
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1 AND purchased_job_slots > 0",
)
.bind(company.id)
.execute(&mut *tx)
.await;
match deduct_result {
Ok(r) if r.rows_affected() == 1 => {}
Ok(_) => {
let _ = tx.rollback().await;
return ( return (
StatusCode::PAYMENT_REQUIRED, StatusCode::PAYMENT_REQUIRED,
Json(serde_json::json!({ Json(serde_json::json!({
@ -217,20 +268,13 @@ async fn create_job(
})) }))
).into_response(); ).into_response();
} }
Err(e) => {
// Deduct ONE purchased slot let _ = tx.rollback().await;
let deduct_result = sqlx::query(
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
)
.bind(company.id)
.execute(&state.pool)
.await;
if let Err(e) = deduct_result {
tracing::error!("Failed to deduct job slot: {}", e); tracing::error!("Failed to deduct job slot: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to deduct quota" }))).into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to deduct quota" }))).into_response();
} }
} }
}
// ----------------------- // -----------------------
let db_payload = DbCreateJobPayload { let db_payload = DbCreateJobPayload {
@ -246,8 +290,40 @@ async fn create_job(
skills: payload.skills, skills: payload.skills,
}; };
match JobRepository::create(&state.pool, db_payload).await { let job = match sqlx::query_as::<_, db::models::job::Job>(
Ok(job) => { r#"
INSERT INTO jobs (
company_id, title, category, description, location,
job_type, salary_min, salary_max, experience_years, skills
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
"#,
)
.bind(db_payload.company_id)
.bind(db_payload.title)
.bind(db_payload.category)
.bind(db_payload.description)
.bind(db_payload.location)
.bind(db_payload.job_type.unwrap_or_else(|| "FULL_TIME".to_string()))
.bind(db_payload.salary_min)
.bind(db_payload.salary_max)
.bind(db_payload.experience_years)
.bind(db_payload.skills.unwrap_or_default())
.fetch_one(&mut *tx)
.await
{
Ok(job) => job,
Err(e) => {
let _ = tx.rollback().await;
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
}
};
if let Err(e) = tx.commit().await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response();
}
// Invalidate company's job list cache // Invalidate company's job list cache
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let pattern = format!("jobs:company:{}:*", company.id); let pattern = format!("jobs:company:{}:*", company.id);
@ -256,10 +332,8 @@ async fn create_job(
let _ = redis.del::<_, ()>(keys).await; let _ = redis.del::<_, ()>(keys).await;
} }
} }
(StatusCode::CREATED, Json(job)).into_response() (StatusCode::CREATED, Json(job)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
}
} }
async fn get_job( async fn get_job(
@ -672,10 +746,56 @@ async fn view_contact(
return (StatusCode::FORBIDDEN, "Access denied").into_response(); return (StatusCode::FORBIDDEN, "Access denied").into_response();
} }
let free_views = company.free_contact_views; // Claiming the quota (or confirming it's already been spent on this
let purchased_views = company.purchased_contact_views; // application) all happens in one transaction with company_profiles'
// row locked FOR UPDATE, so two concurrent view_contact calls for the
// same company can't both read the same pre-decrement counters and
// both pass the quota check -- the second call blocks until the first
// commits, then sees the real, already-decremented balance.
//
// job_applications.contact_unlocked_at is what makes this idempotent:
// without it, re-opening (or simply refreshing) the same application's
// contact panel would re-spend the allowance every time, not just under
// concurrency -- there was previously no record of "already unlocked
// this one" at all.
let mut tx = match state.pool.begin().await {
Ok(tx) => tx,
Err(e) => {
tracing::error!("Failed to start contact-unlock transaction: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
}
};
let already_unlocked: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT contact_unlocked_at FROM job_applications WHERE id = $1 FOR UPDATE",
)
.bind(id)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
let already_unlocked = matches!(already_unlocked, Some(Some(_)));
let (used_free, new_free, new_purchased) = if already_unlocked {
(false, company.free_contact_views, company.purchased_contact_views)
} else {
let (free_views, purchased_views): (i32, i32) = match sqlx::query_as(
"SELECT free_contact_views, purchased_contact_views FROM company_profiles WHERE id = $1 FOR UPDATE",
)
.bind(company.id)
.fetch_one(&mut *tx)
.await
{
Ok(row) => row,
Err(e) => {
let _ = tx.rollback().await;
tracing::error!("Failed to lock company_profiles for contact unlock: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
}
};
if free_views <= 0 && purchased_views <= 0 { if free_views <= 0 && purchased_views <= 0 {
let _ = tx.rollback().await;
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
"error": "Contact view quota exhausted", "error": "Contact view quota exhausted",
"code": "QUOTA_EXHAUSTED", "code": "QUOTA_EXHAUSTED",
@ -686,22 +806,53 @@ async fn view_contact(
let used_free = free_views > 0; let used_free = free_views > 0;
if used_free { let decrement_result = if used_free {
sqlx::query( sqlx::query(
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1" "UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1 AND free_contact_views > 0"
) )
.bind(company.id) .bind(company.id)
.execute(&state.pool) .execute(&mut *tx)
.await .await
.ok();
} else { } else {
sqlx::query( sqlx::query(
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1" "UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1 AND purchased_contact_views > 0"
) )
.bind(company.id) .bind(company.id)
.execute(&state.pool) .execute(&mut *tx)
.await .await
.ok(); };
match decrement_result {
Ok(r) if r.rows_affected() == 1 => {}
_ => {
let _ = tx.rollback().await;
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
"error": "Contact view quota exhausted",
"code": "QUOTA_EXHAUSTED",
"requires_purchase": true,
"message": "You have used all your free contact views. Please purchase a contact view package to continue."
}))).into_response();
}
}
if let Err(e) = sqlx::query("UPDATE job_applications SET contact_unlocked_at = NOW() WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
tracing::error!("Failed to record contact unlock for application {}: {}", id, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
}
let new_free = if used_free { free_views - 1 } else { free_views };
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
(used_free, new_free, new_purchased)
};
if let Err(e) = tx.commit().await {
tracing::error!("Failed to commit contact-unlock transaction: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response();
} }
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>( let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
@ -717,9 +868,9 @@ async fn view_contact(
match contact { match contact {
Ok(Some((name, email, phone))) => { Ok(Some((name, email, phone))) => {
let new_free = if used_free { free_views - 1 } else { free_views }; // Only notify the applicant the first time this contact is
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 }; // actually unlocked -- not on every idempotent re-fetch.
if !already_unlocked {
let _ = sqlx::query( let _ = sqlx::query(
r#" r#"
INSERT INTO notifications (user_id, title, body, type, reference_id) INSERT INTO notifications (user_id, title, body, type, reference_id)
@ -734,6 +885,7 @@ async fn view_contact(
.execute(&state.pool) .execute(&state.pool)
.await .await
.ok(); .ok();
}
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"application_id": id, "application_id": id,

View file

@ -64,6 +64,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
// Spawn hourly Accepted-Lead Expiry task (7-day engagement window).
let p_lead_req = pool.clone();
let m_lead_req = Arc::clone(&mailer);
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(60 * 60));
loop {
interval.tick().await;
tracing::info!("Running Accepted-Lead Expiry Task...");
if let Err(e) = tasks::lead_requests::expire_accepted_leads(&p_lead_req, &m_lead_req).await {
tracing::error!("Accepted-Lead Expiry Task Failed: {}", e);
}
}
});
// Spawn Daily Reminder task // Spawn Daily Reminder task
let p_rem_sys = pool.clone(); let p_rem_sys = pool.clone();
let m_rem_sys = Arc::clone(&mailer); let m_rem_sys = Arc::clone(&mailer);
@ -136,6 +150,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
// Spawn Tracecoin purchase-expiry task (daily -- 3-month validity
// window, matches the cadence of the analogous AI-credit expiry task).
let p_tc_expiry = pool.clone();
let m_tc_expiry = Arc::clone(&mailer);
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(24 * 60 * 60));
loop {
interval.tick().await;
tracing::info!("Running Tracecoin Purchase Expiry Task...");
if let Err(e) = tasks::tracecoin_expiry::expire_purchased_tracecoin_buckets(&p_tc_expiry, &m_tc_expiry).await {
tracing::error!("Tracecoin Purchase Expiry Task Failed: {}", e);
}
}
});
// Keep main thread alive // Keep main thread alive
tokio::signal::ctrl_c().await?; tokio::signal::ctrl_c().await?;
tracing::info!("Shutting down cron engine."); tracing::info!("Shutting down cron engine.");

View file

@ -23,7 +23,7 @@ pub async fn expire_stale_jobs(
r#" r#"
UPDATE jobs UPDATE jobs
SET status = 'EXPIRED' SET status = 'EXPIRED'
FROM companies c FROM company_profiles c
JOIN users u ON u.id = c.user_id JOIN users u ON u.id = c.user_id
WHERE jobs.company_id = c.id WHERE jobs.company_id = c.id
AND jobs.status = 'LIVE' AND jobs.status = 'LIVE'

View file

@ -0,0 +1,122 @@
//! Expires ACCEPTED lead_requests 7 days after acceptance -- the checklist's
//! "Lead validity: 7 days" rule. Distinct from tasks::leads, which expires
//! PENDING (never-accepted) requests after 24h and refunds the reserved
//! Tracecoins; this task is about ACCEPTED leads whose engagement window
//! has simply run out.
//!
//! No wallet/ledger involvement here: by the time a lead_request reaches
//! ACCEPTED, its Tracecoins were already permanently debited from
//! `reserved` (see apps/customers/src/handlers.rs::approve_request ->
//! try_debit_reserved_tracecoins) -- the professional already paid for and
//! received the accepted lead. Expiry is purely a status/visibility change
//! marking the engagement window closed, not a financial reversal.
use sqlx::PgPool;
use email::Mailer;
use chrono::Utc;
use uuid::Uuid;
const LEAD_VALIDITY_DAYS: i64 = 7;
pub async fn expire_accepted_leads(
pool: &PgPool,
mailer: &Mailer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cutoff = Utc::now() - chrono::Duration::days(LEAD_VALIDITY_DAYS);
#[derive(sqlx::FromRow)]
struct Record {
lead_request_id: Uuid,
professional_user_id: Option<Uuid>,
professional_email: Option<String>,
professional_name: Option<String>,
lead_title: Option<String>,
customer_user_id: Option<Uuid>,
}
let records = sqlx::query_as::<_, Record>(
r#"
SELECT
lr.id AS lead_request_id,
lr.professional_user_id,
u.email AS professional_email,
u.full_name AS professional_name,
l.title AS lead_title,
lr.customer_user_id
FROM lead_requests lr
LEFT JOIN users u ON u.id = lr.professional_user_id
LEFT JOIN leads l ON l.id = lr.lead_id
WHERE lr.status = 'ACCEPTED'
AND lr.resolved_at IS NOT NULL
AND lr.resolved_at < $1
"#,
)
.bind(cutoff)
.fetch_all(pool)
.await?;
if records.is_empty() {
return Ok(());
}
tracing::info!("Found {} accepted lead(s) past the {}-day validity window to expire.", records.len(), LEAD_VALIDITY_DAYS);
for rec in records {
// Guarded on status = 'ACCEPTED' so this is safe to run concurrently
// with itself (e.g. an overrunning previous invocation) or to race
// a customer/professional action on the same lead_request without
// double-processing.
let updated = sqlx::query(
"UPDATE lead_requests SET status = 'EXPIRED', updated_at = NOW() WHERE id = $1 AND status = 'ACCEPTED'",
)
.bind(rec.lead_request_id)
.execute(pool)
.await?;
if updated.rows_affected() == 0 {
continue;
}
let title = rec.lead_title.unwrap_or_else(|| "your lead".to_string());
if let Some(professional_user_id) = rec.professional_user_id {
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, 'LEAD_EXPIRED', $4)
"#,
)
.bind(professional_user_id)
.bind("Lead engagement window closed")
.bind(format!("Your accepted lead \"{}\" has passed its {}-day engagement window.", title, LEAD_VALIDITY_DAYS))
.bind(rec.lead_request_id)
.execute(pool)
.await;
}
if let Some(customer_user_id) = rec.customer_user_id {
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, 'LEAD_EXPIRED', $4)
"#,
)
.bind(customer_user_id)
.bind("Lead engagement window closed")
.bind(format!("The engagement window for \"{}\" has closed.", title))
.bind(rec.lead_request_id)
.execute(pool)
.await;
}
if let (Some(email), Some(name)) = (rec.professional_email, rec.professional_name) {
if let Err(e) = mailer.send_requirement_expired_email(&email, &name, &title).await {
tracing::error!("Failed to send lead-expired email to {}: {:?}", email, e);
}
}
tracing::info!("Expired accepted lead_request {} (past {}-day validity window)", rec.lead_request_id, LEAD_VALIDITY_DAYS);
}
Ok(())
}

View file

@ -61,8 +61,20 @@ pub async fn expire_stale_lead_requests(
} }
if rec.tracecoins_reserved > 0 { if rec.tracecoins_reserved > 0 {
// Was `current_balance = current_balance + $1` -- that column
// doesn't exist on tracecoin_wallets (it's `balance`), so this
// UPDATE errored on every run that hit a reserved-coins expiry,
// aborting the whole function via `?` before the transaction
// committed (the EXPIRED status flip above rolled back with
// it). Net effect: leads with reserved coins never actually
// expired, and their coins were never refunded -- silently,
// forever, every 15 minutes. Also restores `reserved`, not just
// `balance` -- the reservation incremented both `reserved` (the
// held amount) and left `balance` unchanged, so releasing it
// must symmetrically decrement `reserved`, or the coins would
// count as both refunded to balance AND still held in reserved.
sqlx::query( sqlx::query(
"UPDATE tracecoin_wallets SET current_balance = current_balance + $1, updated_at = NOW() WHERE user_id = $2" "UPDATE tracecoin_wallets SET balance = balance + $1, reserved = reserved - $1, updated_at = NOW() WHERE user_id = $2"
) )
.bind(rec.tracecoins_reserved) .bind(rec.tracecoins_reserved)
.bind(rec.user_id) .bind(rec.user_id)

View file

@ -1,6 +1,8 @@
pub mod ai; pub mod ai;
pub mod leads; pub mod leads;
pub mod lead_requests;
pub mod requirements; pub mod requirements;
pub mod jobs; pub mod jobs;
pub mod reminders; pub mod reminders;
pub mod ai_credits; pub mod ai_credits;
pub mod tracecoin_expiry;

View file

@ -0,0 +1,124 @@
//! Expires purchased-Tracecoin buckets past their 3-month validity window
//! (crates/db/src/models/tracecoin_wallet.rs::PURCHASE_VALIDITY_DAYS).
//!
//! Buckets track individual purchases (see create_purchase_bucket) and get
//! drawn down FIFO as the user actually spends (consume_purchase_buckets_fifo,
//! called from the real permanent-debit paths). What's left in a bucket by
//! the time it expires is coins the user purchased but never spent -- this
//! task claws that amount back off the wallet's flat `balance` and writes a
//! ledger entry, same as the existing PENDING-lead-request refund pattern.
use sqlx::PgPool;
use email::Mailer;
use uuid::Uuid;
pub async fn expire_purchased_tracecoin_buckets(
pool: &PgPool,
// Kept in the signature to match every other cron task's shape, even
// though it's currently unused -- see the comment near the bottom of
// this function for why (no accurate email template exists yet).
_mailer: &Mailer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let user_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT user_id FROM tracecoin_buckets WHERE expires_at < NOW() AND amount_remaining > 0",
)
.fetch_all(pool)
.await?;
if user_ids.is_empty() {
return Ok(());
}
tracing::info!("Found {} user(s) with expired, unspent Tracecoin purchase buckets.", user_ids.len());
for user_id in user_ids {
let mut tx = pool.begin().await?;
// Lock the wallet for the whole claim -- without this, a concurrent
// spend on this same wallet could debit `balance` between our SUM
// and our UPDATE, and the two would clobber each other's view of
// the correct post-expiry balance.
let wallet = sqlx::query_as::<_, (Uuid, i32)>(
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let Some((wallet_id, balance)) = wallet else {
tx.rollback().await?;
continue;
};
let expired_bucket_ids_and_amounts: Vec<(Uuid, i32)> = sqlx::query_as(
"SELECT id, amount_remaining FROM tracecoin_buckets WHERE user_id = $1 AND expires_at < NOW() AND amount_remaining > 0 FOR UPDATE",
)
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let total_expired: i32 = expired_bucket_ids_and_amounts.iter().map(|(_, a)| a).sum();
if total_expired <= 0 {
tx.rollback().await?;
continue;
}
// Never drive balance negative even if bookkeeping has drifted
// somewhere upstream -- claw back at most what's actually there.
let to_debit = total_expired.min(balance);
if to_debit > 0 {
sqlx::query("UPDATE tracecoin_wallets SET balance = balance - $1, updated_at = NOW() WHERE id = $2")
.bind(to_debit)
.bind(wallet_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'DEBIT', $2, 'TRACECOIN_EXPIRY', NULL)
"#,
)
.bind(wallet_id)
.bind(to_debit)
.execute(&mut *tx)
.await?;
}
for (bucket_id, _) in &expired_bucket_ids_and_amounts {
sqlx::query("UPDATE tracecoin_buckets SET amount_remaining = 0 WHERE id = $1")
.bind(bucket_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, 'TRACECOIN_EXPIRY', NULL)
"#,
)
.bind(user_id)
.bind("Tracecoins expired")
.bind(format!(
"{} unused purchased Tracecoins passed their 3-month validity window and have expired.",
to_debit
))
.execute(pool)
.await;
// No dedicated email template for this exists yet -- the closest
// match (send_lead_expired_email, "lead-expired" template) says
// "tracecoins_returned", which would actively misstate what just
// happened here (coins lost, not refunded). In-app notification
// above is accurate; a real templated email is a follow-up, not
// something to fake with the wrong template.
tracing::info!("Expired {} unspent purchased Tracecoins for user {}", to_debit, user_id);
}
Ok(())
}

View file

@ -25,6 +25,7 @@ pub fn router() -> Router<AppState> {
.route("/requirements", get(list_requirements).post(create_requirement)) .route("/requirements", get(list_requirements).post(create_requirement))
.route("/requirements/{id}", get(get_requirement).patch(update_requirement)) .route("/requirements/{id}", get(get_requirement).patch(update_requirement))
.route("/requirements/{id}/submit", post(submit_requirement)) .route("/requirements/{id}/submit", post(submit_requirement))
.route("/requirements/{id}/urgent", post(mark_requirement_urgent))
.route("/requests", get(list_requests)) .route("/requests", get(list_requests))
.route("/requests/{lead_id}/approve", post(approve_request)) .route("/requests/{lead_id}/approve", post(approve_request))
.route("/requests/{lead_id}/reject", post(reject_request)) .route("/requests/{lead_id}/reject", post(reject_request))
@ -218,10 +219,16 @@ async fn create_requirement(
async fn get_requirement( async fn get_requirement(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
_auth: AuthUser, auth: AuthUser,
) -> impl IntoResponse { ) -> impl IntoResponse {
match RequirementRepository::get_by_id(&state.pool, id).await { match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(req)) => (StatusCode::OK, Json(req)).into_response(), Ok(Some(req)) => {
// Ownership check: customer can only view their own requirements
if req.created_by_user_id != Some(auth.user_id) {
return (StatusCode::NOT_FOUND, "Requirement not found").into_response();
}
(StatusCode::OK, Json(req)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(), Ok(None) => (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
@ -261,6 +268,16 @@ async fn submit_requirement(
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
}; };
// Was missing entirely -- every sibling handler on this same resource
// (get_requirement, update_requirement, mark_requirement_urgent) checks
// ownership; this one didn't, so any authenticated customer could
// submit another customer's DRAFT requirement into the approval queue
// by id, with the verification case and email attributed to whoever
// called this endpoint rather than the actual owner.
if req.created_by_user_id != Some(auth.user_id) {
return (StatusCode::FORBIDDEN, "You do not own this requirement").into_response();
}
if req.status != "DRAFT" { if req.status != "DRAFT" {
return (StatusCode::BAD_REQUEST, "Requirement already submitted or closed").into_response(); return (StatusCode::BAD_REQUEST, "Requirement already submitted or closed").into_response();
} }
@ -300,6 +317,119 @@ async fn submit_requirement(
} }
} }
/// TraceCoins a customer pays to mark their own open requirement urgent.
/// Urgent leads sort first in the professional-facing feed, get a raised
/// request cap (see MAX_REQUESTS_PER_LEAD_URGENT in profession_shared.rs),
/// and trigger an immediate notification to matching professionals. No
/// separate expiry -- urgent status lasts exactly as long as the lead
/// itself (the existing 7-day expires_at).
const URGENT_UPGRADE_COST_TRACECOINS: i32 = 50;
async fn mark_requirement_urgent(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if req.created_by_user_id != Some(auth.user_id) {
return (StatusCode::FORBIDDEN, "You do not own this requirement").into_response();
}
if req.status != "OPEN" {
return (StatusCode::BAD_REQUEST, "Only an open (approved) requirement can be marked urgent").into_response();
}
if req.is_urgent {
return (StatusCode::OK, Json(req)).into_response();
}
// Debit before flagging, not after: try_debit_balance is idempotent on
// (reference_type, reference_id), keyed to this lead's own id, so a
// retry -- or two concurrent requests for the same lead -- can never
// double-charge. Charging first also means a failed/insufficient-funds
// debit leaves nothing to compensate: the lead is simply never flagged.
let debited = match TracecoinWalletRepository::try_debit_balance(
&state.pool,
auth.user_id,
URGENT_UPGRADE_COST_TRACECOINS,
"LEAD_URGENT_UPGRADE",
req.id,
).await {
Ok(true) => true,
Ok(false) => return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
"error": "Insufficient Tracecoins to mark this requirement urgent",
"code": "INSUFFICIENT_FUNDS",
"cost": URGENT_UPGRADE_COST_TRACECOINS,
}))).into_response(),
Err(e) => {
tracing::error!("mark_requirement_urgent debit error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
}
};
debug_assert!(debited);
let updated = match sqlx::query_as::<_, db::models::requirement::Requirement>(
r#"
UPDATE leads
SET is_urgent = true, urgent_at = NOW(), updated_at = NOW()
WHERE id = $1 AND is_urgent = false
RETURNING *
"#,
)
.bind(req.id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(updated)) => updated,
// Already urgent (a concurrent request for the same lead won the
// race) -- the debit above was a no-op for it too, so nothing to
// undo; just report the current state.
Ok(None) => req,
Err(e) => {
tracing::error!("mark_requirement_urgent failed to flag lead {} after successful debit: {}", req.id, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Payment succeeded but failed to mark the requirement urgent -- contact support").into_response();
}
};
// Best-effort instant notification to approved professionals in the
// same profession + location -- not wrapped in the transaction above,
// and never blocks the response.
let notify = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT user_id FROM user_role_profiles
WHERE role_key = $1 AND approval_status = 'APPROVED' AND location ILIKE $2
LIMIT 500
"#,
)
.bind(&updated.profession_key)
.bind(format!("%{}%", updated.location))
.fetch_all(&state.pool)
.await
.unwrap_or_default();
for professional_user_id in notify {
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, 'LEAD_URGENT', $4)
"#,
)
.bind(professional_user_id)
.bind("Urgent lead available")
.bind(format!("A new urgent {} lead was just posted in {}.", updated.profession_key, updated.location))
.bind(updated.id)
.execute(&state.pool)
.await;
}
(StatusCode::OK, Json(updated)).into_response()
}
async fn list_requests( async fn list_requests(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
@ -379,12 +509,21 @@ async fn approve_request(
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
}; };
if lead.status != "PENDING" { // update_status_from's WHERE status = 'PENDING' guard makes this
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(); // atomic against a concurrent approve/reject on the same lead --
// previously this was a plain SELECT-then-branch-then-UPDATE with no
// re-check, so two racing requests could both pass the `lead.status !=
// "PENDING"` check above before either UPDATE committed. See its
// doc-comment for the full reasoning.
let updated = match LeadRequestRepository::update_status_from(&state.pool, lead.id, "PENDING", "ACCEPTED").await {
Ok(Some(updated)) => updated,
Ok(None) => return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(),
Err(e) => {
tracing::error!("approve_request update_status error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
} }
};
match LeadRequestRepository::update_status(&state.pool, lead.id, "ACCEPTED").await {
Ok(updated) => {
match TracecoinWalletRepository::try_debit_reserved_tracecoins( match TracecoinWalletRepository::try_debit_reserved_tracecoins(
&state.pool, &state.pool,
lead.professional_user_id.unwrap(), lead.professional_user_id.unwrap(),
@ -392,22 +531,20 @@ async fn approve_request(
lead.id, lead.id,
).await { ).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(), Ok(false) | Err(_) => {
Err(e) => { // The debit failed after the status flip already committed --
tracing::error!("approve_request debit error: {}", e); // revert PENDING so the lead isn't left stuck ACCEPTED with its
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); // Tracecoins never actually debited from reserved.
if let Err(e) = LeadRequestRepository::update_status_from(&state.pool, lead.id, "ACCEPTED", "PENDING").await {
tracing::error!("approve_request failed to revert status after debit failure for lead {}: {}", lead.id, e);
}
return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response();
} }
} }
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"lead_request": updated, "lead_request": updated,
}))).into_response() }))).into_response()
},
Err(e) => {
tracing::error!("approve_request update_status error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
} }
async fn reject_request( async fn reject_request(
@ -441,32 +578,40 @@ async fn reject_request(
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
}; };
if lead.status != "PENDING" { let updated = match LeadRequestRepository::update_status_from(&state.pool, lead.id, "PENDING", "REJECTED").await {
return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(); Ok(Some(updated)) => updated,
Ok(None) => return (StatusCode::BAD_REQUEST, "Lead already resolved").into_response(),
Err(e) => {
tracing::error!("reject_request update_status error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response();
} }
};
match LeadRequestRepository::update_status(&state.pool, lead.id, "REJECTED").await {
Ok(updated) => {
match TracecoinWalletRepository::try_release_reserved_tracecoins( match TracecoinWalletRepository::try_release_reserved_tracecoins(
&state.pool, &state.pool,
lead.user_role_profile_id.unwrap(), // Was lead.user_role_profile_id -- the wrong id. The wallet is
// keyed by user_id, and try_release_reserved_tracecoins looks it up
// by user_id, so passing the role-profile id meant this never found
// a matching wallet: it silently returned Ok(false), the caller
// returned 409 to the customer, but the status flip to REJECTED
// above had already committed -- permanently stranding the
// professional's reserved Tracecoins with nothing that ever swept
// REJECTED leads to release them. approve_request's debit call
// (above) already used the correct professional_user_id; this now
// matches it.
lead.professional_user_id.unwrap(),
lead.tracecoins_reserved, lead.tracecoins_reserved,
lead.id, lead.id,
"LEAD_REJECTED", "LEAD_REJECTED",
).await { ).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(), Ok(false) | Err(_) => {
Err(e) => { if let Err(e) = LeadRequestRepository::update_status_from(&state.pool, lead.id, "REJECTED", "PENDING").await {
tracing::error!("reject_request release error: {}", e); tracing::error!("reject_request failed to revert status after release failure for lead {}: {}", lead.id, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); }
return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response();
} }
} }
(StatusCode::OK, Json(updated)).into_response() (StatusCode::OK, Json(updated)).into_response()
},
Err(e) => {
tracing::error!("reject_request update_status error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
} }

View file

@ -95,6 +95,7 @@ impl Services {
|| path.starts_with("/api/config") || path.starts_with("/api/config")
|| path.starts_with("/api/kb") || path.starts_with("/api/kb")
|| path.starts_with("/api/packages") || path.starts_with("/api/packages")
|| path.starts_with("/api/coupons")
|| path.starts_with("/api/support") || path.starts_with("/api/support")
|| path.starts_with("/api/reviews") || path.starts_with("/api/reviews")
|| path.starts_with("/api/waitlist") || path.starts_with("/api/waitlist")

View file

@ -542,6 +542,26 @@ async fn apply_to_job(
return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response(); return (StatusCode::TOO_MANY_REQUESTS, "Max 50 active applications").into_response();
} }
// Check for duplicate application before attempting INSERT
let already_applied = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM job_applications WHERE job_id = $1 AND applicant_user_id = $2)"
)
.bind(job.id)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(false);
if already_applied {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"code": "ALREADY_APPLIED",
"error": "You have already applied to this job"
}))
).into_response();
}
let db_payload = CreateApplicationPayload { let db_payload = CreateApplicationPayload {
job_id: job.id, job_id: job.id,
applicant_user_id: auth.user_id, applicant_user_id: auth.user_id,
@ -589,9 +609,15 @@ async fn apply_to_job(
} }
Err(e) => { Err(e) => {
if e.to_string().contains("unique") { if e.to_string().contains("unique") {
(StatusCode::CONFLICT, "Already applied to this job").into_response() (StatusCode::CONFLICT, Json(serde_json::json!({
"code": "ALREADY_APPLIED",
"error": "You have already applied to this job"
}))).into_response()
} else { } else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"code": "INTERNAL_ERROR",
"error": "Failed to submit application"
}))).into_response()
} }
} }
} }

View file

@ -1,8 +1,8 @@
// retrigger-build-marker-2 // retrigger-build-marker-3
#![allow(dead_code)] #![allow(dead_code)]
use axum::{ use axum::{
extract::State, extract::{Path, Query, State},
http::{HeaderValue, Method, StatusCode}, http::{HeaderValue, Method, StatusCode},
routing::get, routing::get,
Json, Router, Json, Router,
@ -38,15 +38,83 @@ pub struct CreateJob {
pub job_type: String, pub job_type: String,
} }
async fn list_jobs(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Job>>, StatusCode> { // BUG-21 fix: add search/filter params to list_jobs
#[derive(Debug, Deserialize)]
pub struct JobQuery {
pub q: Option<String>,
pub location: Option<String>,
pub job_type: Option<String>,
pub status: Option<String>,
pub company_id: Option<uuid::Uuid>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
async fn list_jobs(
State(state): State<Arc<AppState>>,
Query(params): Query<JobQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let location_filter = params.location.as_deref().unwrap_or("");
let job_type_filter = params.job_type.as_deref().unwrap_or("");
let status_filter = params.status.as_deref().unwrap_or("LIVE");
let jobs = sqlx::query_as::<_, Job>( let jobs = sqlx::query_as::<_, Job>(
"SELECT id, title, description, location, job_type, status, created_at FROM jobs ORDER BY created_at DESC" r#"
SELECT id, title, description, location, job_type, status, created_at
FROM jobs
WHERE ($1 = 'LIVE' OR status = $1)
AND ($2 = '' OR LOWER(title) LIKE '%' || $2 || '%'
OR LOWER(description) LIKE '%' || $2 || '%')
AND ($3 = '' OR LOWER(location) LIKE '%' || $3 || '%')
AND ($4 = '' OR job_type = $4)
AND ($5::uuid IS NULL OR company_id = $5)
ORDER BY created_at DESC
LIMIT $6 OFFSET $7
"#,
) )
.bind(status_filter)
.bind(&q)
.bind(location_filter)
.bind(job_type_filter)
.bind(params.company_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(jobs)) let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM jobs
WHERE ($1 = 'LIVE' OR status = $1)
AND ($2 = '' OR LOWER(title) LIKE '%' || $2 || '%'
OR LOWER(description) LIKE '%' || $2 || '%')
AND ($3 = '' OR LOWER(location) LIKE '%' || $3 || '%')
AND ($4 = '' OR job_type = $4)
AND ($5::uuid IS NULL OR company_id = $5)
"#,
)
.bind(status_filter)
.bind(&q)
.bind(location_filter)
.bind(job_type_filter)
.bind(params.company_id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(Json(serde_json::json!({
"jobs": jobs,
"pagination": {
"page": page,
"limit": limit,
"total": total
}
})))
} }
async fn create_job( async fn create_job(
@ -73,7 +141,7 @@ async fn create_job(
async fn get_job( async fn get_job(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
) -> Result<Json<Job>, StatusCode> { ) -> Result<Json<Job>, StatusCode> {
let job = sqlx::query_as::<_, Job>( let job = sqlx::query_as::<_, Job>(
"SELECT id, title, description, location, job_type, status, created_at FROM jobs WHERE id = $1" "SELECT id, title, description, location, job_type, status, created_at FROM jobs WHERE id = $1"
@ -157,8 +225,7 @@ async fn main() {
.expect("PORT must be a valid u16"); .expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Jobs service listening on {}", addr); tracing::info!("Jobs service listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }

View file

@ -15,6 +15,7 @@ anyhow.workspace = true
contracts = { path = "../../crates/contracts" } contracts = { path = "../../crates/contracts" }
db = { path = "../../crates/db" } db = { path = "../../crates/db" }
invoice = { path = "../../crates/invoice" } invoice = { path = "../../crates/invoice" }
wallet = { path = "../../crates/wallet" }
sqlx.workspace = true sqlx.workspace = true
uuid.workspace = true uuid.workspace = true
chrono.workspace = true chrono.workspace = true

View file

@ -177,7 +177,7 @@ async fn list_ledger(
r#" r#"
SELECT SELECT
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
FROM tracecoin_ledger tl FROM tracecoin_ledger tl
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
@ -197,7 +197,7 @@ async fn list_ledger(
r#" r#"
SELECT SELECT
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
FROM tracecoin_ledger tl FROM tracecoin_ledger tl
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
@ -402,7 +402,7 @@ async fn get_credit_ledger(
r#" r#"
SELECT SELECT
tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, tl.id, tl.wallet_id, tw.user_id, u.email AS user_email,
tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, tl.transaction_type AS entry_type, tl.amount, tl.balance_after, tl.reference_type AS reason,
tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at
FROM tracecoin_ledger tl FROM tracecoin_ledger tl
JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id
@ -832,7 +832,7 @@ pub fn admin_router() -> Router<AppState> {
// Tax // Tax
.route("/api/admin/tax", get(list_tax_rules)) .route("/api/admin/tax", get(list_tax_rules))
.route("/api/admin/tax", post(create_tax_rule)) .route("/api/admin/tax", post(create_tax_rule))
.route("/api/admin/tax/:id", delete(delete_tax_rule)) .route("/api/admin/tax/{id}", delete(delete_tax_rule))
// Ledger // Ledger
.route("/api/admin/ledger", get(list_ledger)) .route("/api/admin/ledger", get(list_ledger))
// Orders // Orders
@ -843,11 +843,11 @@ pub fn admin_router() -> Router<AppState> {
.route("/api/admin/credits/adjust", post(adjust_credits)) .route("/api/admin/credits/adjust", post(adjust_credits))
.route("/api/admin/credits/reconcile", get(reconcile_credits)) .route("/api/admin/credits/reconcile", get(reconcile_credits))
.route("/api/admin/credits/reconcile-report", get(reconcile_credits)) .route("/api/admin/credits/reconcile-report", get(reconcile_credits))
.route("/api/admin/credits/reconcile/:order_id", post(reconcile_credits)) .route("/api/admin/credits/reconcile/{order_id}", post(reconcile_credits))
// Invoices // Invoices
.route("/api/admin/invoices", get(list_invoices)) .route("/api/admin/invoices", get(list_invoices))
.route("/api/admin/invoices/:id", get(get_invoice)) .route("/api/admin/invoices/{id}", get(get_invoice))
.route("/api/admin/invoices/:id/html", get(get_invoice_html)) .route("/api/admin/invoices/{id}/html", get(get_invoice_html))
.route("/api/admin/invoices/:id/void", axum::routing::post(void_invoice)) .route("/api/admin/invoices/{id}/void", axum::routing::post(void_invoice))
.route("/api/admin/invoices/:id/mark-paid", axum::routing::post(mark_invoice_paid)) .route("/api/admin/invoices/{id}/mark-paid", axum::routing::post(mark_invoice_paid))
} }

View file

@ -587,7 +587,7 @@ async fn generate_ai_credit_invoice(pool: &PgPool, order: &AiCreditOrderRow, pay
hsn_sac_code: None, hsn_sac_code: None,
quantity: 1.0, quantity: 1.0,
unit_price_paise: pre_discount_price_paise, unit_price_paise: pre_discount_price_paise,
tax_rate_percent: 18.0, tax_rate_percent: invoice::STANDARD_GST_RATE_PERCENT,
metadata: Some(serde_json::json!({ "package_id": order.package_id, "credits": order.credits })), metadata: Some(serde_json::json!({ "package_id": order.package_id, "credits": order.credits })),
}; };

View file

@ -15,9 +15,11 @@ use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid; use uuid::Uuid;
pub mod admin;
pub mod ai_credits; pub mod ai_credits;
pub mod packages; pub mod packages;
pub mod payu; pub mod payu;
pub mod reconcile;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -108,7 +110,7 @@ struct PricingPackageRow {
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
struct UserContactRow { pub(crate) struct UserContactRow {
email: String, email: String,
full_name: Option<String>, full_name: Option<String>,
phone: Option<String>, phone: Option<String>,
@ -173,13 +175,25 @@ pub(crate) fn seller_details() -> invoice::SellerDetails {
/// Never blocks the payment response — invoice generation failures are /// Never blocks the payment response — invoice generation failures are
/// logged, not surfaced to the buyer, since the payment itself already /// logged, not surfaced to the buyer, since the payment itself already
/// succeeded and the wallet was already credited by the time this runs. /// succeeded and the wallet was already credited by the time this runs.
/// Takes the billed party's details directly rather than a `PaymentRow` +
/// `VerifyPaymentRequest`, so it works equally for the live `/verify` call
/// (which has PayU's echoed-back checkout payload to draw from) and the
/// `reconcile` poll job (which only has the payments/users tables to draw
/// from) -- see `finish_successful_payment_side_effects`, the one caller
/// both paths now go through.
#[allow(clippy::too_many_arguments)]
async fn generate_purchase_invoice( async fn generate_purchase_invoice(
pool: &PgPool, pool: &PgPool,
payment: &PaymentRow, payment_id: Uuid,
payload: &VerifyPaymentRequest, user_id: Uuid,
package_id: Option<Uuid>,
amount_inr: i32,
firstname: &str,
email: &str,
phone: Option<&str>,
) { ) {
let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1") let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1")
.bind(payment.package_id) .bind(package_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.ok() .ok()
@ -195,15 +209,15 @@ async fn generate_purchase_invoice(
// payments.amount_inr is already paise (copied straight from // payments.amount_inr is already paise (copied straight from
// pricing_packages.price_inr, itself paise despite the name — see // pricing_packages.price_inr, itself paise despite the name — see
// payu::paise_to_rupee_string dividing by 100), not rupees. // payu::paise_to_rupee_string dividing by 100), not rupees.
unit_price_paise: payment.amount_inr as i64, unit_price_paise: amount_inr as i64,
tax_rate_percent: 18.0, tax_rate_percent: invoice::STANDARD_GST_RATE_PERCENT,
metadata: payment.package_id.map(|id| serde_json::json!({ "package_id": id })), metadata: package_id.map(|id| serde_json::json!({ "package_id": id })),
}; };
let customer = invoice::BillingDetails { let customer = invoice::BillingDetails {
legal_name: payload.firstname.clone(), legal_name: firstname.to_string(),
email: Some(payload.email.clone()), email: Some(email.to_string()),
phone: payload.phone.clone(), phone: phone.map(str::to_string),
gstin: None, gstin: None,
pan: None, pan: None,
billing_address: "Billing address not provided".to_string(), billing_address: "Billing address not provided".to_string(),
@ -211,8 +225,8 @@ async fn generate_purchase_invoice(
}; };
let new_invoice = invoice::service::NewInvoice { let new_invoice = invoice::service::NewInvoice {
payment_id: payment.id, payment_id,
user_id: payment.user_id, user_id,
currency: "INR".to_string(), currency: "INR".to_string(),
invoice_type: "TRACECOIN_PURCHASE".to_string(), invoice_type: "TRACECOIN_PURCHASE".to_string(),
lines: vec![line], lines: vec![line],
@ -229,12 +243,78 @@ async fn generate_purchase_invoice(
Ok(inv) => tracing::info!( Ok(inv) => tracing::info!(
"Generated invoice {} for payment {}", "Generated invoice {} for payment {}",
inv.invoice_number, inv.invoice_number,
payment.id payment_id
), ),
Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment.id, e), Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment_id, e),
} }
} }
/// Everything that must happen once a payment is confirmed SUCCESS and the
/// wallet credited, beyond the credit itself: invoice + notification. The
/// one place both `verify_payment` (browser redirect came back) and
/// `reconcile::reconcile_pending_payments` (browser redirect never came
/// back, PayU polled directly) converge, so a purchase looks identical to
/// the user regardless of which path noticed it succeeded.
///
/// Billing contact comes from `users`, not from a client-supplied payload --
/// the poll path has no payload to draw from, and using the server's own
/// record of the user's name/email for the GST invoice is the more correct
/// choice for `/verify` too (a client posting `/verify` could otherwise put
/// any name/email it likes on its own invoice).
pub(crate) async fn finish_successful_payment_side_effects(
pool: &PgPool,
finalized: &reconcile::FinalizedPayment,
) {
let contact = sqlx::query_as::<_, UserContactRow>("SELECT email, full_name, phone FROM users WHERE id = $1")
.bind(finalized.user_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
let (firstname, email, phone) = match contact {
Some(c) => (
c.full_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.unwrap_or("Customer")
.to_string(),
c.email,
c.phone,
),
None => ("Customer".to_string(), String::new(), None),
};
generate_purchase_invoice(
pool,
finalized.id,
finalized.user_id,
finalized.package_id,
finalized.amount_inr,
&firstname,
&email,
phone.as_deref(),
)
.await;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(finalized.user_id)
.bind("Tracecoins Purchased Successfully")
.bind(format!(
"Your {} Tracecoin package has been credited to your wallet.",
finalized.tracecoins_credited
))
.bind("PAYMENT")
.bind(finalized.id)
.execute(pool)
.await;
}
pub(crate) fn build_txnid() -> String { pub(crate) fn build_txnid() -> String {
format!("tc{}", Uuid::new_v4().simple()) format!("tc{}", Uuid::new_v4().simple())
.chars() .chars()
@ -478,65 +558,45 @@ async fn verify_payment(
} }
if !payload.status.eq_ignore_ascii_case("success") { if !payload.status.eq_ignore_ascii_case("success") {
return Err((StatusCode::BAD_REQUEST, "Payment was not successful".to_string())); // PayU reported a failure/pending status on a hash-verified payload.
} // Record it so the payment doesn't linger as PENDING (and doesn't
// get re-checked forever by the reconciliation poll), same ownership
// The whole claim-and-credit sequence runs in one transaction with the // rules as the success path.
// payments row locked via SELECT ... FOR UPDATE. Without this, two
// concurrent calls with the same (replayed) valid PayU success payload
// could both observe status = 'PENDING' before either commits its
// UPDATE, and both would credit the wallet — a double-credit exploit a
// user fully controls, since this endpoint is called directly by the
// client after PayU redirects back, not by a server-to-server webhook.
let mut tx = state
.pool
.begin()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payment = sqlx::query_as::<_, PaymentRow>( let payment = sqlx::query_as::<_, PaymentRow>(
r#" r#"
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
FROM payments FROM payments
WHERE payu_txnid = $1 AND status = 'PENDING' WHERE payu_txnid = $1
FOR UPDATE
"#, "#,
) )
.bind(&payload.txnid) .bind(&payload.txnid)
.fetch_optional(&mut *tx) .fetch_optional(&state.pool)
.await .await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payment = match payment { let Some(payment) = payment else {
Some(payment) => payment,
None => {
let _ = tx.rollback().await;
return Err(error_response( return Err(error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"Payment not found or already processed", "Payment not found or already processed",
)); ));
}
}; };
if payment.user_id != auth.user_id { if payment.user_id != auth.user_id {
let _ = tx.rollback().await;
return Err(error_response( return Err(error_response(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"Payment does not belong to user", "Payment does not belong to user",
)); ));
} }
if !payload.status.eq_ignore_ascii_case("success") { if payment.status == "PENDING" {
sqlx::query("UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2") let _ = sqlx::query(
"UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2 AND status = 'PENDING'",
)
.bind(&payload.mihpayid) .bind(&payload.mihpayid)
.bind(payment.id) .bind(payment.id)
.execute(&mut *tx) .execute(&state.pool)
.await .await;
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; }
tx.commit()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
return Ok(Json(VerifyPaymentResponse { return Ok(Json(VerifyPaymentResponse {
verified: false, verified: false,
@ -547,79 +607,47 @@ async fn verify_payment(
})); }));
} }
let tracecoins = payment.tracecoins_credited.unwrap_or(0); // The whole claim-and-credit sequence runs in one transaction with the
// payments row locked via SELECT ... FOR UPDATE, gated on status =
sqlx::query( // 'PENDING'. Without this, two concurrent calls with the same
r#" // (replayed) valid PayU success payload could both observe status =
UPDATE payments SET // 'PENDING' before either commits its UPDATE, and both would credit the
status = 'SUCCESS', // wallet — a double-credit exploit a user fully controls, since this
payu_mihpayid = $1, // endpoint is called directly by the client after PayU redirects back,
verified_at = NOW() // not by a server-to-server webhook. The same guard is what makes it
WHERE id = $2 // safe for `reconcile::reconcile_pending_payments` to independently
"#, // re-check (and potentially race) this same txnid later.
let finalized = match reconcile::finalize_successful_payment(
&state.pool,
&payload.txnid,
&payload.mihpayid,
Some(auth.user_id),
) )
.bind(&payload.mihpayid)
.bind(payment.id)
.execute(&mut *tx)
.await .await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
{
reconcile::FinalizeOutcome::Credited(finalized) => finalized,
reconcile::FinalizeOutcome::NotFound => {
return Err(error_response(
StatusCode::NOT_FOUND,
"Payment not found or already processed",
));
}
reconcile::FinalizeOutcome::OwnershipMismatch => {
return Err(error_response(
StatusCode::FORBIDDEN,
"Payment does not belong to user",
));
}
};
let wallet_id: Uuid = sqlx::query_scalar( let reference_number = finalized.reference_number.clone();
r#" finish_successful_payment_side_effects(&state.pool, &finalized).await;
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, $2, 0)
ON CONFLICT (user_id) DO UPDATE SET
balance = tracecoin_wallets.balance + excluded.balance,
updated_at = NOW()
RETURNING id
"#,
)
.bind(payment.user_id)
.bind(tracecoins as i64)
.fetch_one(&mut *tx)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#,
)
.bind(wallet_id)
.bind(tracecoins as i64)
.bind(payment.id)
.execute(&mut *tx)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
tx.commit()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
generate_purchase_invoice(&state.pool, &payment, &payload).await;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(payment.user_id)
.bind("Tracecoins Purchased Successfully")
.bind(format!(
"Your {} Tracecoin package has been credited to your wallet.",
tracecoins
))
.bind("PAYMENT")
.bind(payment.id)
.execute(&state.pool)
.await;
Ok(Json(VerifyPaymentResponse { Ok(Json(VerifyPaymentResponse {
verified: true, verified: true,
payment_id: payload.mihpayid, payment_id: payload.mihpayid,
reference_number: Some(payment.reference_number.clone()), reference_number: Some(reference_number),
status: "success".to_string(), status: "success".to_string(),
message: "Payment verified successfully".to_string(), message: "Payment verified successfully".to_string(),
})) }))
@ -739,6 +767,24 @@ async fn main() {
payu: payu::PayuConfig::from_env(), payu: payu::PayuConfig::from_env(),
}; };
// Backstop for payments whose browser never redirects back to /verify
// (tab closed, network lost, PayU redirect fails) -- see reconcile.rs.
// Skipped when PayU isn't configured (e.g. local dev without the env
// vars set) since every poll would just fail the same way.
if !state.payu.merchant_key.trim().is_empty() && !state.payu.merchant_salt.trim().is_empty() {
let pool_reconcile = state.pool.clone();
let payu_reconcile = state.payu.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5 * 60));
loop {
interval.tick().await;
reconcile::reconcile_pending_payments(&pool_reconcile, &payu_reconcile).await;
}
});
} else {
tracing::warn!("PayU not configured (PAYU_MERCHANT_KEY/PAYU_MERCHANT_SALT) -- payment reconciliation poll disabled");
}
let app = Router::new() let app = Router::new()
.route("/api/payments/create-order", post(create_order)) .route("/api/payments/create-order", post(create_order))
.route("/api/payments/verify", post(verify_payment)) .route("/api/payments/verify", post(verify_payment))
@ -748,6 +794,13 @@ async fn main() {
.nest("/api/packages", packages::router()) .nest("/api/packages", packages::router())
.nest("/api/ai-credits", ai_credits::router()) .nest("/api/ai-credits", ai_credits::router())
.nest("/api/admin/ai-credits/packages", ai_credits::admin_router()) .nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
// admin::admin_router() defines its own full "/api/admin/..." paths
// (tax rules, ledger, orders, credits, invoices), so merge rather
// than nest -- and see the module's own history: this was written
// but never wired up (no `mod admin;`, no dependency on `wallet` in
// Cargo.toml), so every one of these routes 404'd in production
// despite apps/gateway forwarding traffic to them.
.merge(admin::admin_router())
.with_state(state); .with_state(state);
let port: u16 = std::env::var("PORT") let port: u16 = std::env::var("PORT")

View file

@ -114,6 +114,79 @@ pub fn verify_response_hash(
expected.eq_ignore_ascii_case(received_hash) expected.eq_ignore_ascii_case(received_hash)
} }
/// Result of a `verify_payment` API call against PayU — the server-to-server
/// status check used to reconcile orders whose browser never redirected
/// back to `/api/payments/verify` (see `reconcile::reconcile_pending_payments`).
#[derive(Debug, Clone)]
pub struct PayuTransactionStatus {
pub status: String,
pub mihpayid: String,
}
/// Calls PayU's `verify_payment` API (server-to-server, not the browser
/// redirect flow) for a single txnid. Returns `Ok(None)` if PayU has no
/// record of the transaction yet (e.g. the user never actually completed
/// checkout) rather than treating that as an error worth retrying loudly.
///
/// Unlike `verify_response_hash`, this call is not itself hash-verified on
/// the way back -- the trust boundary here is the outbound HTTPS request
/// authenticated by our merchant key/salt, the same way any card-not-present
/// merchant-initiated status check works. There is no client-supplied data
/// in this path for a forged hash to smuggle through.
pub async fn query_transaction_status(
config: &PayuConfig,
txnid: &str,
) -> Result<Option<PayuTransactionStatus>, anyhow::Error> {
const COMMAND: &str = "verify_payment";
let hash = sha512_hex(&format!(
"{}|{}|{}|{}",
config.merchant_key, COMMAND, txnid, config.merchant_salt
));
let url = format!("{}/merchant/postservice?form=2", config.base_url);
let resp: serde_json::Value = reqwest::Client::new()
.post(&url)
.form(&[
("key", config.merchant_key.as_str()),
("command", COMMAND),
("var1", txnid),
("hash", hash.as_str()),
])
.send()
.await?
.json()
.await?;
// PayU nests the per-txnid result under `transaction_details.<txnid>`;
// some API generations report it under `result.<txnid>` instead. Try
// both shapes rather than betting on one.
let entry = resp
.get("transaction_details")
.and_then(|v| v.get(txnid))
.or_else(|| resp.get("result").and_then(|v| v.get(txnid)));
let Some(entry) = entry else {
return Ok(None);
};
let status = entry
.get("status")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let mihpayid = entry
.get("mihpayid")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if status.is_empty() {
return Ok(None);
}
Ok(Some(PayuTransactionStatus { status, mihpayid }))
}
pub fn generate_txnid() -> String { pub fn generate_txnid() -> String {
uuid::Uuid::new_v4().to_string().replace('-', "") uuid::Uuid::new_v4().to_string().replace('-', "")
} }

View file

@ -0,0 +1,281 @@
//! Shared "claim a PENDING payment and credit the wallet" logic, plus the
//! background poll job that backstops it.
//!
//! `verify_payment` (apps/payments/src/main.rs) is the happy-path caller:
//! the browser redirects back from PayU and the client hits `/verify`. But
//! that only fires if the browser makes it back at all -- close the tab, lose
//! network, or PayU's redirect just fails, and a payment that actually
//! succeeded on PayU's side sits at `status = 'PENDING'` forever with
//! nothing to notice. `reconcile_pending_payments` is the backstop: poll
//! PayU directly for any payment that's been PENDING too long and finish
//! the job server-side.
//!
//! `finalize_successful_payment` is the one place that ever moves a payment
//! to SUCCESS and credits the wallet, used by both callers, so there is
//! exactly one implementation of the double-credit-proof claim (`SELECT ...
//! FOR UPDATE` gated on `status = 'PENDING'`) to reason about.
use sqlx::{postgres::PgPool, FromRow};
use uuid::Uuid;
use crate::payu;
#[derive(Debug, FromRow)]
struct ClaimRow {
id: Uuid,
reference_number: String,
user_id: Uuid,
package_id: Option<Uuid>,
tracecoins_credited: Option<i32>,
amount_inr: i32,
}
/// A payment that was just moved PENDING -> SUCCESS and credited, with
/// enough data for the caller to generate an invoice / notify the user.
pub struct FinalizedPayment {
pub id: Uuid,
pub user_id: Uuid,
pub reference_number: String,
pub tracecoins_credited: i32,
pub amount_inr: i32,
pub package_id: Option<Uuid>,
}
pub enum FinalizeOutcome {
Credited(FinalizedPayment),
/// No PENDING payment exists for this txnid -- already handled by
/// another caller (verify_payment vs. the poll job racing, or a
/// duplicate PayU status callback), or the txnid is unknown to us.
NotFound,
/// A PENDING payment exists but belongs to a different user than the
/// caller claims. Only possible from `verify_payment`'s auth'd path;
/// the poll job never passes an `expected_user_id`.
OwnershipMismatch,
}
/// Claims a PENDING payment by txnid and credits the wallet, atomically.
///
/// The whole claim-and-credit sequence runs in one transaction with the
/// payments row locked via `SELECT ... FOR UPDATE`, gated on `status =
/// 'PENDING'`. That's what makes this safe to call twice for the same
/// txnid (verify_payment fires, then the poll job independently notices the
/// same payment before or after): whichever call reaches the row first
/// moves it out of PENDING inside its own transaction, so the second call
/// simply finds nothing to claim and returns `NotFound` -- never a second
/// credit.
///
/// `expected_user_id`, when set, must match the payment's owner or the
/// claim is refused before any credit happens (used by `verify_payment`,
/// which is called by a client-supplied JWT; the poll job passes `None`
/// since it isn't acting on behalf of any particular request).
pub async fn finalize_successful_payment(
pool: &PgPool,
txnid: &str,
mihpayid: &str,
expected_user_id: Option<Uuid>,
) -> Result<FinalizeOutcome, sqlx::Error> {
let mut tx = pool.begin().await?;
let payment = sqlx::query_as::<_, ClaimRow>(
r#"
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr
FROM payments
WHERE payu_txnid = $1 AND status = 'PENDING'
FOR UPDATE
"#,
)
.bind(txnid)
.fetch_optional(&mut *tx)
.await?;
let payment = match payment {
Some(payment) => payment,
None => {
tx.rollback().await.ok();
return Ok(FinalizeOutcome::NotFound);
}
};
if let Some(expected) = expected_user_id {
if payment.user_id != expected {
tx.rollback().await.ok();
return Ok(FinalizeOutcome::OwnershipMismatch);
}
}
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
sqlx::query(
r#"
UPDATE payments SET
status = 'SUCCESS',
payu_mihpayid = $1,
verified_at = NOW()
WHERE id = $2
"#,
)
.bind(mihpayid)
.bind(payment.id)
.execute(&mut *tx)
.await?;
let wallet_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, $2, 0)
ON CONFLICT (user_id) DO UPDATE SET
balance = tracecoin_wallets.balance + excluded.balance,
updated_at = NOW()
RETURNING id
"#,
)
.bind(payment.user_id)
.bind(tracecoins as i64)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#,
)
.bind(wallet_id)
.bind(tracecoins as i64)
.bind(payment.id)
.execute(&mut *tx)
.await?;
// "Purchased credits valid for 3 months" -- this PayU credit is the
// only real Tracecoin *purchase* path in the codebase, so it's the
// only place that should create an expiring bucket. reference_id =
// payment.id makes this idempotent the same way the ledger insert
// above already is (ON CONFLICT DO NOTHING on the bucket's own unique
// index), so a retried finalize for the same payment can't create two.
db::models::tracecoin_wallet::TracecoinWalletRepository::create_purchase_bucket(
&mut tx,
payment.user_id,
tracecoins,
"PAYU_PURCHASE",
payment.id,
)
.await?;
tx.commit().await?;
Ok(FinalizeOutcome::Credited(FinalizedPayment {
id: payment.id,
user_id: payment.user_id,
reference_number: payment.reference_number,
tracecoins_credited: tracecoins,
amount_inr: payment.amount_inr,
package_id: payment.package_id,
}))
}
#[derive(Debug, FromRow)]
struct StalePendingRow {
id: Uuid,
payu_txnid: Option<String>,
}
/// How long a payment can sit PENDING before we start asking PayU about it.
/// Short enough that legitimate slow redirects (a few minutes on a bad
/// connection) aren't chased, long enough that we're not hammering PayU for
/// orders still mid-checkout.
const RECONCILE_AFTER_MINUTES: i64 = 15;
/// Payments older than this are almost certainly abandoned carts (user
/// never paid at all), not missed callbacks -- stop polling PayU about them
/// so the poll batch stays dominated by payments actually worth chasing.
const GIVE_UP_AFTER_DAYS: i64 = 7;
const BATCH_SIZE: i64 = 25;
/// One pass of the reconciliation poll: find payments stuck PENDING past
/// `RECONCILE_AFTER_MINUTES`, ask PayU directly what happened to each, and
/// finalize (credit) or fail them accordingly. Meant to be called on a
/// timer from `main`, the same way the `cron` app's tasks are.
pub async fn reconcile_pending_payments(pool: &PgPool, payu: &payu::PayuConfig) {
let stale = match sqlx::query_as::<_, StalePendingRow>(
r#"
SELECT id, payu_txnid
FROM payments
WHERE status = 'PENDING'
AND created_at < NOW() - ($1 || ' minutes')::interval
AND created_at > NOW() - ($2 || ' days')::interval
ORDER BY created_at ASC
LIMIT $3
"#,
)
.bind(RECONCILE_AFTER_MINUTES.to_string())
.bind(GIVE_UP_AFTER_DAYS.to_string())
.bind(BATCH_SIZE)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::error!("Payment reconciliation: failed to query stale PENDING payments: {e}");
return;
}
};
if stale.is_empty() {
return;
}
tracing::info!("Payment reconciliation: checking {} stale PENDING payment(s) against PayU", stale.len());
for row in stale {
let Some(txnid) = row.payu_txnid else {
// No txnid was ever recorded -- nothing to ask PayU about.
continue;
};
match payu::query_transaction_status(payu, &txnid).await {
Ok(Some(status)) if status.status.eq_ignore_ascii_case("success") => {
match finalize_successful_payment(pool, &txnid, &status.mihpayid, None).await {
Ok(FinalizeOutcome::Credited(finalized)) => {
tracing::info!(
"Payment reconciliation: credited abandoned-redirect payment {} ({} tracecoins)",
finalized.id,
finalized.tracecoins_credited
);
crate::finish_successful_payment_side_effects(pool, &finalized).await;
}
Ok(FinalizeOutcome::NotFound) => {
// Someone else (verify_payment, or a previous poll
// that raced this one) already claimed it -- fine.
}
Ok(FinalizeOutcome::OwnershipMismatch) => {
// Unreachable: the poll job never passes
// expected_user_id, so this arm can't occur here.
}
Err(e) => {
tracing::error!("Payment reconciliation: failed to finalize payment {}: {e}", row.id);
}
}
}
Ok(Some(status)) if status.status.eq_ignore_ascii_case("failure") => {
if let Err(e) = sqlx::query(
"UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2 AND status = 'PENDING'",
)
.bind(&status.mihpayid)
.bind(row.id)
.execute(pool)
.await
{
tracing::error!("Payment reconciliation: failed to mark payment {} FAILED: {e}", row.id);
}
}
Ok(_) => {
// Still pending at PayU (or PayU has no record yet) --
// leave it alone, we'll check again next pass.
}
Err(e) => {
tracing::error!("Payment reconciliation: PayU status query failed for txnid {txnid}: {e}");
}
}
}
}

View file

@ -7,7 +7,7 @@ use axum::{
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -49,11 +49,13 @@ struct PaginatedResponse {
} }
async fn list_activity_logs( async fn list_activity_logs(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<ListQuery>, Query(params): Query<ListQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Ensure admin permission (require_admin will be applied by router if nested under /api/admin) if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let page = params.page.unwrap_or(1).max(1); let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(50).clamp(1, 100); let limit = params.limit.unwrap_or(50).clamp(1, 100);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;

View file

@ -6,7 +6,7 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use sqlx::FromRow; use sqlx::FromRow;
@ -40,10 +40,11 @@ pub struct AdminUserRow {
} }
async fn list_users( async fn list_users(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ListQuery>, Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let search = q.q.as_deref().unwrap_or_default().to_lowercase(); let search = q.q.as_deref().unwrap_or_default().to_lowercase();
let role_filter = q.role.as_deref().unwrap_or_default().to_uppercase(); let role_filter = q.role.as_deref().unwrap_or_default().to_uppercase();
@ -104,10 +105,11 @@ async fn list_users(
} }
async fn list_customers( async fn list_customers(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ListQuery>, Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let search = q.q.unwrap_or_default().to_lowercase(); let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#" let sql = r#"
@ -132,10 +134,11 @@ async fn list_customers(
} }
async fn list_candidates( async fn list_candidates(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ListQuery>, Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let search = q.q.unwrap_or_default().to_lowercase(); let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#" let sql = r#"
@ -165,11 +168,12 @@ pub struct StatusPayload {
} }
async fn update_user_status( async fn update_user_status(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<StatusPayload>, Json(payload): Json<StatusPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2") sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2")
.bind(&payload.status) .bind(&payload.status)
.bind(id) .bind(id)

View file

@ -583,9 +583,12 @@ pub struct AnalyticsQuery {
async fn analytics_overview( async fn analytics_overview(
State(state): State<AppState>, State(state): State<AppState>,
_auth: AuthUser, auth: AuthUser,
axum::extract::Query(q): axum::extract::Query<AnalyticsQuery>, axum::extract::Query(q): axum::extract::Query<AnalyticsQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let days = q.days.unwrap_or(7).clamp(1, 90); let days = q.days.unwrap_or(7).clamp(1, 90);
let totals_row = sqlx::query( let totals_row = sqlx::query(

View file

@ -152,10 +152,22 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
let normalized_intent = intent.map(normalize_role_key).unwrap_or_default(); let normalized_intent = intent.map(normalize_role_key).unwrap_or_default();
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty()); let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
if normalized_intent.is_empty() { // BUG-26 fix: when role_key is sent directly (aliased → profession field)
// and intent is not provided, treat the profession as the intent.
// e.g. register with role_key=PHOTOGRAPHER → profession=PHOTOGRAPHER, intent=None
let effective_intent = if normalized_intent.is_empty() {
normalized_profession.clone().unwrap_or_default()
} else {
normalized_intent.clone()
};
if effective_intent.is_empty() {
return vec![]; return vec![];
} }
// Re-alias for clarity in the checks below
let normalized_intent = effective_intent;
if normalized_intent.contains("COMPANY") { if normalized_intent.contains("COMPANY") {
return vec!["COMPANY".to_string()]; return vec!["COMPANY".to_string()];
} }
@ -172,6 +184,26 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
return vec![]; return vec![];
} }
// BUG-26 fix: if the intent IS a known professional role key directly
// (e.g., PHOTOGRAPHER, MAKEUP_ARTIST, TUTOR) — return it as-is.
// These are sent by some flows that pass the role_key directly instead
// of passing "PROFESSIONAL" + profession.
const KNOWN_PROFESSIONAL_ROLES: &[&str] = &[
"PHOTOGRAPHER",
"MAKEUP_ARTIST",
"TUTOR",
"DEVELOPER",
"VIDEO_EDITOR",
"GRAPHIC_DESIGNER",
"SOCIAL_MEDIA_MANAGER",
"FITNESS_TRAINER",
"CATERING_SERVICES",
"UGC_CONTENT_CREATOR",
];
if KNOWN_PROFESSIONAL_ROLES.contains(&normalized_intent.as_str()) {
return vec![normalized_intent];
}
vec![] vec![]
} }
@ -433,6 +465,24 @@ async fn register(
} else { } else {
tracing::warn!(role_key = %role_key, "Role not found in database"); tracing::warn!(role_key = %role_key, "Role not found in database");
} }
// BUG-26 fix: auto-create user_role_profiles row for every role at registration.
// All professional-type services and job_seeker/company/customer UX rely on
// this row existing. Without it, the first PATCH /profile/me returns 500.
let display_name = role_display_name_from_code(&role_key);
let _ = sqlx::query(
r#"
INSERT INTO user_role_profiles (user_id, role_key, display_name, status, verification_status, approval_status)
VALUES ($1, $2, $3, 'ACTIVE', 'PENDING', 'PENDING')
ON CONFLICT (user_id, role_key) DO NOTHING
"#,
)
.bind(user.id)
.bind(&role_key)
.bind(&display_name)
.execute(&state.pool)
.await;
tracing::info!(role_key = %role_key, "Auto-created user_role_profiles row");
} }
// For demo accounts: auto-verify email and skip OTP // For demo accounts: auto-verify email and skip OTP

View file

@ -32,7 +32,7 @@ pub fn dashboard_router() -> Router<AppState> {
pub fn runtime_router() -> Router<AppState> { pub fn runtime_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(get_my_runtime_config).post(create_runtime_config)) .route("/", get(get_my_runtime_config))
.route("/{role_id}", get(get_active_runtime_config)) .route("/{role_id}", get(get_active_runtime_config))
} }

View file

@ -6,7 +6,7 @@ use axum::{
routing::{get, patch, post}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -19,6 +19,12 @@ pub fn coupons_router() -> Router<AppState> {
.route("/validate", post(validate_coupon)) .route("/validate", post(validate_coupon))
} }
/// User-facing coupon validation router — mounted at /api/coupons
pub fn user_coupons_router() -> Router<AppState> {
Router::new()
.route("/validate", post(user_validate_coupon))
}
pub fn discounts_router() -> Router<AppState> { pub fn discounts_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_discounts).post(create_discount)) .route("/", get(list_discounts).post(create_discount))
@ -178,9 +184,12 @@ struct ExistingDiscountRow {
// ── Coupon handlers ─────────────────────────────────────────────────────────── // ── Coupon handlers ───────────────────────────────────────────────────────────
async fn list_coupons( async fn list_coupons(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let rows = sqlx::query_as::<_, CouponRow>( let rows = sqlx::query_as::<_, CouponRow>(
r#" r#"
SELECT id, code, title, discount_type, discount_value, min_order_amount, SELECT id, code, title, discount_type, discount_value, min_order_amount,
@ -221,10 +230,13 @@ async fn list_coupons(
} }
async fn create_coupon( async fn create_coupon(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateCouponBody>, Json(body): Json<CreateCouponBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string()); let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
let value = body.value.unwrap_or(0); let value = body.value.unwrap_or(0);
let min_order = body.min_order_amount.unwrap_or(0); let min_order = body.min_order_amount.unwrap_or(0);
@ -282,11 +294,14 @@ async fn create_coupon(
} }
async fn update_coupon( async fn update_coupon(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchCouponBody>, Json(body): Json<PatchCouponBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let existing = sqlx::query_as::<_, ExistingCouponRow>( let existing = sqlx::query_as::<_, ExistingCouponRow>(
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1", "SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
) )
@ -342,10 +357,13 @@ async fn update_coupon(
} }
async fn delete_coupon( async fn delete_coupon(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query("DELETE FROM coupons WHERE id = $1") let result = sqlx::query("DELETE FROM coupons WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -366,9 +384,12 @@ async fn delete_coupon(
// ── Discount handlers ───────────────────────────────────────────────────────── // ── Discount handlers ─────────────────────────────────────────────────────────
async fn list_discounts( async fn list_discounts(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let rows = sqlx::query_as::<_, DiscountRow>( let rows = sqlx::query_as::<_, DiscountRow>(
r#" r#"
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
@ -404,10 +425,13 @@ async fn list_discounts(
} }
async fn create_discount( async fn create_discount(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateDiscountBody>, Json(body): Json<CreateDiscountBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let scope = body.scope.unwrap_or_else(|| "ROLE".to_string()); let scope = body.scope.unwrap_or_else(|| "ROLE".to_string());
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string()); let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
let value = body.value.unwrap_or(0); let value = body.value.unwrap_or(0);
@ -450,11 +474,14 @@ async fn create_discount(
} }
async fn update_discount( async fn update_discount(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchDiscountBody>, Json(body): Json<PatchDiscountBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let existing = sqlx::query_as::<_, ExistingDiscountRow>( let existing = sqlx::query_as::<_, ExistingDiscountRow>(
"SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1", "SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1",
) )
@ -521,10 +548,11 @@ struct ValidateCouponResponse {
} }
async fn validate_coupon( async fn validate_coupon(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ValidateCouponPayload>, Json(payload): Json<ValidateCouponPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let code = payload.coupon_code.trim().to_uppercase(); let code = payload.coupon_code.trim().to_uppercase();
// Fetch coupon // Fetch coupon
@ -610,7 +638,7 @@ async fn validate_coupon(
discount_type: None, discount_type: None,
discount_value: None, discount_value: None,
final_price_inr: payload.package_price_inr, final_price_inr: payload.package_price_inr,
message: format!("Minimum order amount ₹{} required", coupon.min_order_amount / 100), message: format!("Minimum order amount ₹{} required", coupon.min_order_amount),
}), }),
)); ));
} }
@ -659,3 +687,103 @@ async fn validate_coupon(
}), }),
)) ))
} }
// ── User-facing coupon validation (BUG-44 fix) ────────────────────────────────
//
// POST /api/coupons/validate — same logic as admin validate_coupon but without
// require_admin(). Allows authenticated users to validate a coupon code at
// checkout before initiating payment.
async fn user_validate_coupon(
_auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<ValidateCouponPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// No require_admin — any authenticated user may validate a coupon
let code = payload.coupon_code.trim().to_uppercase();
let coupon = sqlx::query_as::<_, ValidateCouponRow>(
r#"
SELECT id, code, discount_type, discount_value, min_order_amount,
max_uses, role_keys, valid_until, is_active
FROM coupons
WHERE code = $1
"#,
)
.bind(&code)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let coupon = match coupon {
Some(c) => c,
None => return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr, message: "Coupon not found".to_string(),
}))),
};
if !coupon.is_active {
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr, message: "Coupon is inactive".to_string(),
})));
}
if let Some(valid_until) = coupon.valid_until {
if valid_until < chrono::Utc::now() {
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr, message: "Coupon has expired".to_string(),
})));
}
}
if !coupon.role_keys.is_empty() && !coupon.role_keys.iter().any(|r| r.eq_ignore_ascii_case(&payload.role_key)) {
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr, message: "Coupon not valid for your role".to_string(),
})));
}
if payload.package_price_inr < coupon.min_order_amount {
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr,
message: format!("Minimum order amount ₹{} required", coupon.min_order_amount),
})));
}
if let Some(max_uses) = coupon.max_uses {
let count: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1",
)
.bind(coupon.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if count >= max_uses as i64 {
return Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: false, discount_type: None, discount_value: None,
final_price_inr: payload.package_price_inr, message: "Coupon usage limit reached".to_string(),
})));
}
}
let final_price = match coupon.discount_type.as_str() {
"PERCENT" => {
let discount = ((payload.package_price_inr as f64) * (coupon.discount_value as f64) / 100.0).round() as i32;
(payload.package_price_inr - discount).max(0)
}
"FIXED" => (payload.package_price_inr - coupon.discount_value).max(0),
_ => payload.package_price_inr,
};
Ok((StatusCode::OK, Json(ValidateCouponResponse {
valid: true,
discount_type: Some(coupon.discount_type),
discount_value: Some(coupon.discount_value),
final_price_inr: final_price,
message: "Coupon applied".to_string(),
})))
}

View file

@ -6,7 +6,7 @@ use axum::{
routing::{get, patch, post}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -17,6 +17,8 @@ pub fn public_router() -> Router<AppState> {
Router::new() Router::new()
.route("/categories", get(public_list_categories)) .route("/categories", get(public_list_categories))
.route("/articles", get(public_list_articles)) .route("/articles", get(public_list_articles))
// /search is an alias for /articles?q=... so frontend can use either
.route("/search", get(public_list_articles))
.route("/articles/{slug}", get(public_get_article)) .route("/articles/{slug}", get(public_get_article))
.route("/articles/id/{id}", get(public_get_article_by_id)) .route("/articles/id/{id}", get(public_get_article_by_id))
} }
@ -413,9 +415,12 @@ async fn public_get_article_by_id(
// ── Admin: categories ───────────────────────────────────────────────────────── // ── Admin: categories ─────────────────────────────────────────────────────────
async fn admin_list_categories( async fn admin_list_categories(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let rows = sqlx::query_as::<_, CategoryWithCountRow>( let rows = sqlx::query_as::<_, CategoryWithCountRow>(
r#" r#"
SELECT SELECT
@ -466,10 +471,13 @@ struct CreateCategoryBody {
} }
async fn admin_create_category( async fn admin_create_category(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateCategoryBody>, Json(body): Json<CreateCategoryBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let order = body.display_order.unwrap_or(0); let order = body.display_order.unwrap_or(0);
let result = sqlx::query_as::<_, CategoryRow>( let result = sqlx::query_as::<_, CategoryRow>(
r#" r#"
@ -525,11 +533,14 @@ struct UpdateCategoryBody {
} }
async fn admin_update_category( async fn admin_update_category(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<UpdateCategoryBody>, Json(body): Json<UpdateCategoryBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query_as::<_, CategoryRow>( let result = sqlx::query_as::<_, CategoryRow>(
r#" r#"
UPDATE kb_categories SET UPDATE kb_categories SET
@ -582,10 +593,13 @@ async fn admin_update_category(
} }
async fn admin_delete_category( async fn admin_delete_category(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(dead_code)] #[allow(dead_code)]
struct IdRow { id: Uuid } struct IdRow { id: Uuid }
@ -629,10 +643,13 @@ struct AdminArticleQuery {
} }
async fn admin_list_articles( async fn admin_list_articles(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<AdminArticleQuery>, Query(params): Query<AdminArticleQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let q = params.q.as_deref().unwrap_or("").to_lowercase(); let q = params.q.as_deref().unwrap_or("").to_lowercase();
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string()); let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
@ -707,6 +724,9 @@ async fn admin_create_article(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateArticleBody>, Json(body): Json<CreateArticleBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let slug = body let slug = body
.slug .slug
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@ -778,10 +798,13 @@ async fn admin_create_article(
} }
async fn admin_get_article( async fn admin_get_article(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let row = sqlx::query_as::<_, AdminArticleRow>( let row = sqlx::query_as::<_, AdminArticleRow>(
r#" r#"
SELECT SELECT
@ -847,11 +870,14 @@ struct UpdateArticleBody {
} }
async fn admin_update_article( async fn admin_update_article(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<UpdateArticleBody>, Json(body): Json<UpdateArticleBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let status: Option<String> = body.status.as_deref().map(|s| s.to_string()); let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
let result = sqlx::query_as::<_, InsertedArticleRow>( let result = sqlx::query_as::<_, InsertedArticleRow>(
r#" r#"
@ -919,10 +945,13 @@ async fn admin_update_article(
} }
async fn admin_delete_article( async fn admin_delete_article(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(dead_code)] #[allow(dead_code)]
struct IdRow { id: Uuid } struct IdRow { id: Uuid }
@ -978,6 +1007,9 @@ async fn admin_ai_draft_article(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<AiDraftArticleBody>, Json(body): Json<AiDraftArticleBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let category = body.category.as_deref().unwrap_or("General"); let category = body.category.as_deref().unwrap_or("General");
let hints = body.topic_hints.as_deref().unwrap_or(""); let hints = body.topic_hints.as_deref().unwrap_or("");

View file

@ -37,9 +37,10 @@ struct PersonaTypeRow {
} }
async fn list_persona_types( async fn list_persona_types(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let rows = sqlx::query_as::<_, PersonaTypeRow>( let rows = sqlx::query_as::<_, PersonaTypeRow>(
"SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name", "SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name",
) )

View file

@ -6,7 +6,7 @@ use axum::{
routing::{get, patch}, routing::{get, patch},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -60,6 +60,7 @@ struct CreatePackageBody {
tracecoin_amount: Option<i32>, tracecoin_amount: Option<i32>,
price_inr: i32, price_inr: i32,
description: Option<String>, description: Option<String>,
is_active: Option<bool>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -167,9 +168,12 @@ async fn public_list_packages(
} }
async fn list_packages( async fn list_packages(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let rows = sqlx::query_as::<_, PackageRow>( let rows = sqlx::query_as::<_, PackageRow>(
r#" r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
@ -207,20 +211,24 @@ async fn list_packages(
} }
async fn create_package( async fn create_package(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreatePackageBody>, Json(body): Json<CreatePackageBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string()); let package_type = body.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
// Accept tracecoin_amount (admin UI) or tracecoins_amount // Accept tracecoin_amount (admin UI) or tracecoins_amount
let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(0); let tracecoins_amount = body.tracecoins_amount.or(body.tracecoin_amount).unwrap_or(0);
// Accept role (admin UI) or role_key // Accept role (admin UI) or role_key
let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string()); let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string());
let is_active = body.is_active.unwrap_or(true);
let row = sqlx::query_as::<_, PackageRow>( let row = sqlx::query_as::<_, PackageRow>(
r#" r#"
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description) INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
"#, "#,
) )
@ -230,6 +238,7 @@ async fn create_package(
.bind(tracecoins_amount) .bind(tracecoins_amount)
.bind(body.price_inr) .bind(body.price_inr)
.bind(&body.description) .bind(&body.description)
.bind(is_active)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
@ -257,11 +266,14 @@ async fn create_package(
} }
async fn update_package( async fn update_package(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchPackageBody>, Json(body): Json<PatchPackageBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let existing = sqlx::query_as::<_, ExistingPackageRow>( let existing = sqlx::query_as::<_, ExistingPackageRow>(
"SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1", "SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1",
) )
@ -315,10 +327,13 @@ async fn update_package(
} }
async fn delete_package( async fn delete_package(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1") let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -339,10 +354,13 @@ async fn delete_package(
// ── Report handlers ─────────────────────────────────────────────────────────── // ── Report handlers ───────────────────────────────────────────────────────────
async fn report_users( async fn report_users(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DateRangeQuery>, Query(params): Query<DateRangeQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let from = params.from.as_deref().unwrap_or("2000-01-01"); let from = params.from.as_deref().unwrap_or("2000-01-01");
let to = params.to.as_deref().unwrap_or("2099-12-31"); let to = params.to.as_deref().unwrap_or("2099-12-31");
@ -397,10 +415,13 @@ async fn report_users(
} }
async fn report_revenue( async fn report_revenue(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DateRangeQuery>, Query(params): Query<DateRangeQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let from = params.from.as_deref().unwrap_or("2000-01-01"); let from = params.from.as_deref().unwrap_or("2000-01-01");
let to = params.to.as_deref().unwrap_or("2099-12-31"); let to = params.to.as_deref().unwrap_or("2099-12-31");

View file

@ -737,7 +737,50 @@ async fn submit_for_verification(
None => fetch_saved_profile(&state, auth.user_id, &role_key).await, None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
}; };
let documents = extract_documents(&profile_data); let mut documents = extract_documents(&profile_data);
// For JOB_SEEKER role: also pull documents uploaded via /api/jobseeker/profile/documents
// (stored in job_seeker_documents table, separate from profile_data JSONB)
if role_key == "JOB_SEEKER" {
if let Ok(rows) = sqlx::query(
r#"
SELECT d.document_type, d.file_url, d.file_name, d.mime_type
FROM job_seeker_documents d
JOIN job_seeker_profiles p ON p.id = d.job_seeker_id
WHERE p.user_id = $1
ORDER BY d.created_at
"#,
)
.bind(auth.user_id)
.fetch_all(&state.pool)
.await
{
use sqlx::Row;
let existing_urls: std::collections::HashSet<String> = if let serde_json::Value::Array(ref arr) = documents {
arr.iter()
.filter_map(|d| d.get("value").and_then(|v| v.as_str()).map(String::from))
.collect()
} else {
std::collections::HashSet::new()
};
if let serde_json::Value::Array(ref mut arr) = documents {
for row in rows {
let url: String = row.try_get("file_url").unwrap_or_default();
if !url.is_empty() && !existing_urls.contains(&url) {
let doc_type: String = row.try_get("document_type").unwrap_or_else(|_| "document".to_string());
let file_name: String = row.try_get("file_name").unwrap_or_default();
arr.push(serde_json::json!({
"type": doc_type,
"value": url,
"file_name": file_name,
"status": "SUBMITTED"
}));
}
}
}
}
}
// Mark profile as PENDING in role-specific table // Mark profile as PENDING in role-specific table
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await; set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;

View file

@ -3,10 +3,10 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get, routing::{get, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -21,105 +21,86 @@ pub fn admin_router() -> Router<AppState> {
pub fn public_router() -> Router<AppState> { pub fn public_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_reviews)) .route("/", get(list_reviews))
.route("/professional/{professional_id}", get(list_reviews_by_professional)) .route("/submit", post(submit_review))
.route("/entity/{entity_type}/{entity_id}", get(list_reviews_by_entity))
} }
// ── DTOs ────────────────────────────────────────────────────────────────────── // ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize, sqlx::FromRow)]
struct ReviewDto { struct ReviewDto {
id: Uuid, id: Uuid,
professional_id: Uuid, reviewer_user_id: Option<Uuid>,
customer_id: Uuid, reviewer_name: Option<String>,
rating: i16, entity_type: String,
comment: Option<String>, entity_id: Option<String>,
subject_type: String,
subject_id: Option<String>,
title: Option<String>,
rating: Option<i16>,
review_text: Option<String>,
is_published: bool, is_published: bool,
status: String,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Serialize)]
struct PublicReviewDto {
id: Uuid,
professional_id: Uuid,
rating: i16,
comment: Option<String>,
created_at: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateReviewBody { struct CreateReviewBody {
#[allow(dead_code)] entity_type: Option<String>,
lead_request_id: Uuid, entity_id: Option<String>,
subject_type: Option<String>,
subject_id: Option<String>,
title: Option<String>,
rating: i16, rating: i16,
comment: Option<String>, review_text: Option<String>,
reviewer_name: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct PatchReviewBody { struct PatchReviewBody {
is_published: Option<bool>, is_published: Option<bool>,
status: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct PublicListQuery { struct PublicListQuery {
page: Option<i64>, page: Option<i64>,
limit: Option<i64>, limit: Option<i64>,
} entity_type: Option<String>,
entity_id: Option<String>,
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ReviewRow {
id: Uuid,
#[allow(dead_code)]
lead_request_id: Uuid,
customer_id: Uuid,
professional_id: Uuid,
rating: i16,
comment: Option<String>,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
} }
// ── Handlers ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
async fn admin_list_reviews( async fn admin_list_reviews(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<PublicListQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let rows = sqlx::query_as::<_, ReviewRow>( if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewDto>(
r#" r#"
SELECT SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
r.id, subject_type, subject_id, title, rating, review_text,
r.lead_request_id, is_published, status, created_at
r.customer_id, FROM reviews
r.professional_id, ORDER BY created_at DESC
r.rating, LIMIT $1 OFFSET $2
r.comment,
r.is_published,
r.created_at
FROM reviews r
ORDER BY r.created_at DESC
"#, "#,
) )
.bind(limit)
.bind(offset)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
match rows { match rows {
Ok(rows) => { Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "reviews": rows }))).into_response(),
let dtos: Vec<ReviewDto> = rows
.into_iter()
.map(|r| ReviewDto {
id: r.id,
professional_id: r.professional_id,
customer_id: r.customer_id,
rating: r.rating,
comment: r.comment,
is_published: r.is_published,
created_at: r.created_at,
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to list reviews: {e}"); tracing::error!("Failed to list reviews: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response() (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
@ -128,43 +109,41 @@ async fn admin_list_reviews(
} }
async fn admin_create_review( async fn admin_create_review(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateReviewBody>, Json(body): Json<CreateReviewBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
if body.rating < 1 || body.rating > 5 { if body.rating < 1 || body.rating > 5 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response(); return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
} }
let row = sqlx::query_as::<_, ReviewRow>( let row = sqlx::query_as::<_, ReviewDto>(
r#" r#"
INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published) INSERT INTO reviews (reviewer_user_id, entity_type, entity_id, subject_type, subject_id,
SELECT $1, title, rating, review_text, reviewer_name, is_published, status)
(SELECT id FROM customer_profiles WHERE user_id = (SELECT customer_user_id FROM lead_requests WHERE id = $1)), VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, 'PUBLISHED')
(SELECT user_role_profile_id FROM lead_requests WHERE id = $1), RETURNING id, reviewer_user_id, reviewer_name, entity_type, entity_id,
$2, $3, true subject_type, subject_id, title, rating, review_text,
RETURNING id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at is_published, status, created_at
"#, "#,
) )
.bind(body.lead_request_id) .bind(auth.user_id)
.bind(body.entity_type.as_deref().unwrap_or("PLATFORM"))
.bind(&body.entity_id)
.bind(body.subject_type.as_deref().unwrap_or("PLATFORM"))
.bind(&body.subject_id)
.bind(&body.title)
.bind(body.rating) .bind(body.rating)
.bind(&body.comment) .bind(&body.review_text)
.bind(&body.reviewer_name)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
match row { match row {
Ok(r) => { Ok(r) => (StatusCode::CREATED, Json(r)).into_response(),
let dto = ReviewDto {
id: r.id,
professional_id: r.professional_id,
customer_id: r.customer_id,
rating: r.rating,
comment: r.comment,
is_published: r.is_published,
created_at: r.created_at,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to create review: {e}"); tracing::error!("Failed to create review: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create review" }))).into_response() (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create review" }))).into_response()
@ -173,17 +152,19 @@ async fn admin_create_review(
} }
async fn admin_update_review( async fn admin_update_review(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>, Json(body): Json<PatchReviewBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let is_published = body.is_published.unwrap_or(true); if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query( let result = sqlx::query(
"UPDATE reviews SET is_published = $1, updated_at = NOW() WHERE id = $2", "UPDATE reviews SET is_published = COALESCE($1, is_published), status = COALESCE($2, status), updated_at = NOW() WHERE id = $3",
) )
.bind(is_published) .bind(body.is_published)
.bind(&body.status)
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await; .await;
@ -201,10 +182,13 @@ async fn admin_update_review(
} }
async fn admin_delete_review( async fn admin_delete_review(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query("DELETE FROM reviews WHERE id = $1") let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@ -232,34 +216,28 @@ async fn list_reviews(
let limit = q.limit.unwrap_or(20).clamp(1, 100); let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>( let rows = sqlx::query_as::<_, ReviewDto>(
r#" r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
subject_type, subject_id, title, rating, review_text,
is_published, status, created_at
FROM reviews FROM reviews
WHERE is_published = true WHERE is_published = true AND status = 'PUBLISHED'
AND ($1::text IS NULL OR entity_type = $1)
AND ($2::text IS NULL OR entity_id = $2)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2 LIMIT $3 OFFSET $4
"#, "#,
) )
.bind(&q.entity_type)
.bind(&q.entity_id)
.bind(limit) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
match rows { match rows {
Ok(rows) => { Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "reviews": rows }))).into_response(),
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to list public reviews: {e}"); tracing::error!("Failed to list public reviews: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response() (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
@ -267,25 +245,69 @@ async fn list_reviews(
} }
} }
async fn list_reviews_by_professional( async fn submit_review(
auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(professional_id): Path<Uuid>, Json(body): Json<CreateReviewBody>,
) -> impl IntoResponse {
if body.rating < 1 || body.rating > 5 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
}
let row = sqlx::query_as::<_, ReviewDto>(
r#"
INSERT INTO reviews (reviewer_user_id, entity_type, entity_id, subject_type, subject_id,
title, rating, review_text, reviewer_name, is_published, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, 'PUBLISHED')
RETURNING id, reviewer_user_id, reviewer_name, entity_type, entity_id,
subject_type, subject_id, title, rating, review_text,
is_published, status, created_at
"#,
)
.bind(auth.user_id)
.bind(body.entity_type.as_deref().unwrap_or("PLATFORM"))
.bind(&body.entity_id)
.bind(body.subject_type.as_deref().unwrap_or("PLATFORM"))
.bind(&body.subject_id)
.bind(&body.title)
.bind(body.rating)
.bind(&body.review_text)
.bind(&body.reviewer_name)
.fetch_one(&state.pool)
.await;
match row {
Ok(r) => (StatusCode::CREATED, Json(r)).into_response(),
Err(e) => {
tracing::error!("Failed to submit review: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to submit review" }))).into_response()
}
}
}
async fn list_reviews_by_entity(
State(state): State<AppState>,
Path((entity_type, entity_id)): Path<(String, String)>,
Query(q): Query<PublicListQuery>, Query(q): Query<PublicListQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1); let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100); let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>( let rows = sqlx::query_as::<_, ReviewDto>(
r#" r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at SELECT id, reviewer_user_id, reviewer_name, entity_type, entity_id,
subject_type, subject_id, title, rating, review_text,
is_published, status, created_at
FROM reviews FROM reviews
WHERE professional_id = $1 AND is_published = true WHERE entity_type = $1 AND entity_id = $2
AND is_published = true AND status = 'PUBLISHED'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 OFFSET $3 LIMIT $3 OFFSET $4
"#, "#,
) )
.bind(professional_id) .bind(&entity_type)
.bind(&entity_id)
.bind(limit) .bind(limit)
.bind(offset) .bind(offset)
.fetch_all(&state.pool) .fetch_all(&state.pool)
@ -293,34 +315,32 @@ async fn list_reviews_by_professional(
match rows { match rows {
Ok(rows) => { Ok(rows) => {
let avg: (f64,) = sqlx::query_as("SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE professional_id = $1 AND is_published = true") let avg: (f64,) = sqlx::query_as(
.bind(professional_id) "SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE entity_type = $1 AND entity_id = $2 AND is_published = true AND status = 'PUBLISHED'"
)
.bind(&entity_type)
.bind(&entity_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.unwrap_or((0.0,)); .unwrap_or((0.0,));
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
.bind(professional_id) let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM reviews WHERE entity_type = $1 AND entity_id = $2 AND is_published = true AND status = 'PUBLISHED'"
)
.bind(&entity_type)
.bind(&entity_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.unwrap_or((0,)); .unwrap_or((0,));
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"reviews": dtos, "reviews": rows,
"averageRating": avg.0, "averageRating": avg.0,
"totalCount": count.0 "totalCount": count.0
}))).into_response() }))).into_response()
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to list reviews for professional {professional_id}: {e}"); tracing::error!("Failed to list reviews for {entity_type}/{entity_id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response() (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
} }
} }

View file

@ -6,7 +6,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -411,6 +411,8 @@ async fn user_get_ticket(
#[derive(Deserialize)] #[derive(Deserialize)]
struct AddMessageBody { struct AddMessageBody {
/// Accepts `body` or `message` (frontend alias).
#[serde(alias = "message")]
body: String, body: String,
} }
@ -513,10 +515,13 @@ struct AdminTicketRow {
} }
async fn admin_list_cases( async fn admin_list_cases(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<AdminListQuery>, Query(params): Query<AdminListQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let page = params.page.unwrap_or(1).max(1); let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(50).clamp(1, 200); let limit = params.limit.unwrap_or(50).clamp(1, 200);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
@ -606,10 +611,13 @@ struct AdminCreateCaseBody {
} }
async fn admin_create_case( async fn admin_create_case(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<AdminCreateCaseBody>, Json(body): Json<AdminCreateCaseBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string()); let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
let priority = body.priority.unwrap_or_else(|| "medium".to_string()); let priority = body.priority.unwrap_or_else(|| "medium".to_string());
@ -665,10 +673,13 @@ async fn admin_create_case(
// ── Admin: get case with messages ───────────────────────────────────────────── // ── Admin: get case with messages ─────────────────────────────────────────────
async fn admin_get_case( async fn admin_get_case(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let ticket = sqlx::query_as::<_, AdminTicketRow>( let ticket = sqlx::query_as::<_, AdminTicketRow>(
r#" r#"
SELECT SELECT
@ -774,11 +785,14 @@ struct UpdatedTicketRow {
} }
async fn admin_update_case( async fn admin_update_case(
_auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<UpdateCaseBody>, Json(body): Json<UpdateCaseBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let result = sqlx::query_as::<_, UpdatedTicketRow>( let result = sqlx::query_as::<_, UpdatedTicketRow>(
r#" r#"
UPDATE support_tickets SET UPDATE support_tickets SET
@ -861,6 +875,9 @@ async fn admin_add_message(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<AdminAddMessageBody>, Json(body): Json<AdminAddMessageBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let is_internal = body.is_internal.unwrap_or(false); let is_internal = body.is_internal.unwrap_or(false);
let exists = sqlx::query_scalar::<_, bool>( let exists = sqlx::query_scalar::<_, bool>(

View file

@ -78,6 +78,9 @@ async fn main() {
.nest("/api/admin/users", handlers::admin::router()) .nest("/api/admin/users", handlers::admin::router())
.nest("/api/me/roles", handlers::user_roles::router()) .nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ───────────────────────────────────────────────── // ── Notifications ─────────────────────────────────────────────────
// BUG-05 fix: also mount at /api/notifications so gateway route
// /api/notifications → users service works regardless of prefix
.nest("/api/notifications", handlers::notifications::router())
.nest("/api/me/notifications", handlers::notifications::router()) .nest("/api/me/notifications", handlers::notifications::router())
.nest("/api/me/settings", handlers::settings::router()) .nest("/api/me/settings", handlers::settings::router())
// ── Admin: Approvals (jobs/requirements) ───────────────────────── // ── Admin: Approvals (jobs/requirements) ─────────────────────────
@ -116,6 +119,8 @@ async fn main() {
// ── Coupons & Discounts (admin) ─────────────────────────────────── // ── Coupons & Discounts (admin) ───────────────────────────────────
.nest("/api/admin/coupons", handlers::coupons::coupons_router()) .nest("/api/admin/coupons", handlers::coupons::coupons_router())
.nest("/api/admin/discounts", handlers::coupons::discounts_router()) .nest("/api/admin/discounts", handlers::coupons::discounts_router())
// ── Coupons (user-facing, BUG-44 fix) ───────────────────────────────
.nest("/api/coupons", handlers::coupons::user_coupons_router())
.nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router()) .nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router())
// ── Tracecoin Packages (public) ─────────────────────────────────── // ── Tracecoin Packages (public) ───────────────────────────────────
.nest("/api/packages", handlers::pricing::public_packages_router()) .nest("/api/packages", handlers::pricing::public_packages_router())

View file

@ -36,6 +36,21 @@ pub struct LeadRequestPayload {
pub message: Option<String>, pub message: Option<String>,
} }
#[derive(Deserialize)]
pub struct OpenLeadsQuery {
pub location: Option<String>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
/// Standard per-lead request cap -- how many professionals can send a
/// request before a requirement stops accepting new ones.
const MAX_REQUESTS_PER_LEAD: i32 = 10;
/// Raised cap for a customer-paid urgent lead (see mark_requirement_urgent
/// in apps/customers/src/handlers.rs) -- more competing requests is the
/// point of paying to be urgent.
const MAX_REQUESTS_PER_LEAD_URGENT: i32 = 20;
/// Build the shared Router that every profession service merges into its own Router. /// Build the shared Router that every profession service merges into its own Router.
/// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`. /// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`.
pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> { pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
@ -82,6 +97,13 @@ pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
) )
.route("/marketplace/{id}", get(get_requirement)) .route("/marketplace/{id}", get(get_requirement))
// ── Lead Requests ──────────────────────────────────────────────────── // ── Lead Requests ────────────────────────────────────────────────────
.route(
"/leads/open",
get({
let pk = profession_key;
move |state, auth, query| list_open_leads(state, auth, query, pk)
}),
)
.route( .route(
"/leads/request", "/leads/request",
post( post(
@ -130,6 +152,55 @@ async fn get_requirement(
} }
} }
/// Browsable feed of open leads (requirements) matching this profession --
/// distinct from GET /marketplace above, which lists *professional
/// profiles* for customers to browse. This is the other direction:
/// professionals browsing open leads to request. Originally built reusing
/// the `/marketplace` path, which collided with that existing route and
/// crash-looped every profession service on next restart (`Overlapping
/// method route` panic, axum panics at router-build time, not
/// request time, so this sat latent from deploy until the next restart) --
/// moved to /leads/open, alongside this profession's other /leads/*
/// endpoints. Urgent (customer-paid) leads sort first, then newest first;
/// expired/closed/rejected leads never appear.
async fn list_open_leads(
State(state): State<ProfessionState>,
_auth: AuthUser,
Query(query): Query<OpenLeadsQuery>,
profession_key: &'static str,
) -> impl IntoResponse {
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let location_filter = query.location.map(|l| format!("%{}%", l));
let rows = sqlx::query_as::<_, db::models::requirement::Requirement>(
r#"
SELECT * FROM leads
WHERE profession_key = $1
AND status = 'OPEN'
AND (expires_at IS NULL OR expires_at > NOW())
AND ($2::TEXT IS NULL OR location ILIKE $2)
ORDER BY is_urgent DESC, created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(profession_key)
.bind(&location_filter)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({
"data": rows,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn send_lead_request( async fn send_lead_request(
State(state): State<ProfessionState>, State(state): State<ProfessionState>,
auth: AuthUser, auth: AuthUser,
@ -186,7 +257,10 @@ async fn send_lead_request(
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
}; };
if req.request_count >= 20 { // Urgent (customer-paid) leads get a raised request cap -- more
// professionals competing for it is the point of paying to be urgent.
let request_cap = if req.is_urgent { MAX_REQUESTS_PER_LEAD_URGENT } else { MAX_REQUESTS_PER_LEAD };
if req.request_count >= request_cap {
return (StatusCode::CONFLICT, "Requirement reached max requests").into_response(); return (StatusCode::CONFLICT, "Requirement reached max requests").into_response();
} }

View file

@ -0,0 +1,2 @@
-- No-op: 20260814050000's up migration recreates the trigger, and its own
-- down migration is responsible for dropping it again.

View file

@ -0,0 +1,9 @@
-- Prod has an `invoices_payment_id_check` trigger that was created outside
-- of sqlx's migration tracking (never recorded in _sqlx_migrations), which
-- made 20260814050000_invoices_payment_id_polymorphic_check.up.sql fail
-- with "trigger already exists" and stop the whole migrate run partway
-- through, blocking every migration after it. Drop the untracked trigger
-- here (a version strictly before 050000) so 050000 can recreate it
-- cleanly and the run proceeds.
DROP TRIGGER IF EXISTS invoices_payment_id_check ON invoices;

View file

@ -0,0 +1,5 @@
BEGIN;
UPDATE pricing_packages SET price_inr = price_inr / 100 WHERE price_inr >= 100 AND price_inr < 1000000 AND price_inr != 25000;
COMMIT;

View file

@ -0,0 +1,19 @@
-- pricing_packages.price_inr is treated as PAISE by every consumer (see
-- apps/payments/src/main.rs's paise_to_rupee_string call, and the comment
-- on that column at apps/payments/src/main.rs:196) but 58 of the 59 rows
-- (all but the JOB_SEEKER "Starter Pack" seeded correctly at 25000 = ₹250
-- in 20260721070000_seed_pricing_packages.up.sql) were inserted with plain
-- rupee-looking values (499, 999, 1999, 4999, 9999, ...). PayU would divide
-- by 100 and charge 1/100th of the intended price, e.g. a "₹999 Growth"
-- package would actually charge ₹9.99.
--
-- Threshold price_inr < 10000 catches exactly the mis-seeded rows and
-- leaves the correctly-seeded 25000 row untouched. Confirmed no purchases
-- have gone through pricing_packages yet (this is pre-launch), so this is
-- a data-fix, not a refund situation.
BEGIN;
UPDATE pricing_packages SET price_inr = price_inr * 100 WHERE price_inr < 10000;
COMMIT;

View file

@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE job_applications
DROP CONSTRAINT IF EXISTS job_applications_job_id_applicant_user_id_key;
COMMIT;

View file

@ -0,0 +1,36 @@
-- apps/job_seekers/src/handlers.rs::apply_to_job does a check-then-insert
-- (SELECT EXISTS ... then INSERT, no transaction/lock) to block duplicate
-- applications, which is race-condition-prone: two concurrent requests can
-- both pass the EXISTS check before either INSERT lands, producing two
-- applications for the same (job_id, applicant_user_id). The handler's
-- INSERT error path already anticipates this - `if e.to_string().contains
-- ("unique")` returns 409 ALREADY_APPLIED - but the unique constraint it's
-- expecting was never created, so that branch was dead code. This adds it
-- as the actual DB-level backstop.
--
-- Safe to run live: any pre-existing duplicates would violate the new
-- constraint and abort the migration, so this fails loudly rather than
-- silently if the race has already produced duplicate rows somewhere.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql file on every deploy, so every migration must
-- be safe to run more than once. This one originally wasn't: a bare ADD
-- CONSTRAINT on a second run errored with "already exists" and, since the
-- runner aborts the whole batch on first error, silently blocked every
-- migration alphabetically after it from ever applying again. Matches the
-- DO $$ ... pg_constraint pattern already used elsewhere in this directory
-- (e.g. 20260721030000_create_lead_requests.up.sql).
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_applications_job_id_applicant_user_id_key') THEN
ALTER TABLE job_applications
ADD CONSTRAINT job_applications_job_id_applicant_user_id_key
UNIQUE (job_id, applicant_user_id);
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_unlocked_at;
COMMIT;

View file

@ -0,0 +1,18 @@
-- apps/companies/src/handlers/mod.rs::view_contact spends a company's
-- free_contact_views/purchased_contact_views allowance on every call, with
-- no record of which applications' contacts a company has already unlocked.
-- That means simply reloading the page re-spends the allowance for the same
-- applicant every time (not just a concurrency race -- guaranteed
-- double-consumption even one request at a time), and concurrent requests
-- can additionally race the check-then-decrement on company_profiles's
-- counters past zero.
--
-- This column lets view_contact skip the charge (and the counter race)
-- entirely once an application's contact has already been unlocked by its
-- company -- see the accompanying handler fix.
BEGIN;
ALTER TABLE job_applications
ADD COLUMN IF NOT EXISTS contact_unlocked_at TIMESTAMPTZ;
COMMIT;

View file

@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE company_profiles
DROP CONSTRAINT IF EXISTS company_profiles_free_job_slots_non_negative,
DROP CONSTRAINT IF EXISTS company_profiles_purchased_job_slots_non_negative,
DROP CONSTRAINT IF EXISTS company_profiles_free_contact_views_non_negative,
DROP CONSTRAINT IF EXISTS company_profiles_purchased_contact_views_non_negative;
COMMIT;

View file

@ -0,0 +1,39 @@
-- Defense-in-depth alongside the app-layer fixes to view_contact (contact
-- unlock dedup) and create_job (locked quota check): these counters were
-- previously decremented with plain `x = x - 1` and no floor, so a bug in
-- either handler (or any future caller) could still drive them negative
-- with nothing at the DB level to stop it. tracecoin_wallets already has
-- this kind of CHECK for balance/reserved -- these four counters are the
-- same class of "must never go negative" value and deserve the same
-- guarantee, not just app-layer discipline.
--
-- Safe to run live: fails loudly rather than silently if any row has
-- already gone negative, same reasoning as the job_applications unique
-- constraint migration.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql on every deploy, so every migration must
-- tolerate being run more than once. A bare ADD CONSTRAINT here originally
-- didn't, which (since the runner aborts the whole batch on first error)
-- would have silently blocked every migration alphabetically after this
-- one from ever applying on the second deploy onward.
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_job_slots_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_job_slots_non_negative CHECK (free_job_slots >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_job_slots_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_job_slots_non_negative CHECK (purchased_job_slots >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_contact_views_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_contact_views_non_negative CHECK (free_contact_views >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_contact_views_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_contact_views_non_negative CHECK (purchased_contact_views >= 0);
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,11 @@
BEGIN;
DROP FUNCTION IF EXISTS lock_tracecoin_wallet(UUID);
DROP INDEX IF EXISTS uq_tracecoin_ledger_wallet_reference_type;
ALTER TABLE tracecoin_ledger
DROP COLUMN IF EXISTS balance_after,
DROP COLUMN IF EXISTS actor_user_id,
DROP COLUMN IF EXISTS metadata;
COMMIT;

View file

@ -0,0 +1,65 @@
-- crates/wallet (lib.rs + hold.rs) is a second, more sophisticated
-- Tracecoin implementation (idempotent-by-reference credit/reserve/
-- release/confirm/admin_adjust, plus a hold/escrow subsystem) that was
-- built against a `tracecoin_ledger` schema with `type`/`reason`/
-- `balance_after`/`actor_user_id`/`metadata` columns, and a
-- `lock_tracecoin_wallet(uuid)` helper function. Neither the columns nor
-- the function were ever actually created: the migration that would have
-- added them (20260627000000_tracecoin_security_hardening.up.sql.skip) was
-- disabled, and itself assumed columns (`type`, `reason`) that don't match
-- this repo's actual, separately-evolved ledger schema (`transaction_type`,
-- `reference_type` -- see 20260318233000_tracecoin_ledger_immutable.up.sql).
--
-- Net effect: every call into crates/wallet errors at the DB layer before
-- it can do anything (`function lock_tracecoin_wallet(uuid) does not
-- exist`). The one live, reachable caller is the admin manual wallet
-- adjustment endpoint (apps/payments/src/admin.rs -> wallet::admin_adjust),
-- so every admin credit/debit adjustment 500s today. This migration adds
-- what crates/wallet actually needs, reusing the existing
-- transaction_type/reference_type columns (via the handler-side fix that
-- accompanies this migration) instead of introducing a second, competing
-- set of `type`/`reason` columns -- balance_after/actor_user_id/metadata
-- are genuinely new, additive audit fields with no existing equivalent.
BEGIN;
ALTER TABLE tracecoin_ledger
ADD COLUMN IF NOT EXISTS balance_after INTEGER,
ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id),
ADD COLUMN IF NOT EXISTS metadata JSONB;
-- Idempotency: at most one ledger entry per (wallet, reference,
-- transaction_type). Lets a single payment/reservation/admin-action credit
-- or debit exactly once on retry, while still allowing separate RESERVE /
-- RELEASE / DEBIT rows for the same reference_id (e.g. one lead_request).
CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type
ON tracecoin_ledger (wallet_id, reference_id, transaction_type)
WHERE reference_id IS NOT NULL;
-- The single point where concurrent access to a wallet is serialized:
-- get-or-create the wallet row, lock it FOR UPDATE for the rest of the
-- caller's transaction, return its current state. Mirrors the inline
-- SELECT...FOR UPDATE pattern crates/db/src/models/tracecoin_wallet.rs
-- already uses correctly elsewhere in this codebase, just as a reusable
-- function for crates/wallet's callers.
CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID)
RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER)
LANGUAGE plpgsql
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES (p_user_id, 0, 0)
ON CONFLICT (user_id) DO NOTHING;
SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id;
RETURN QUERY
SELECT tw.id, tw.balance, tw.reserved
FROM tracecoin_wallets tw
WHERE tw.id = v_id
FOR UPDATE;
END;
$$;
COMMIT;

View file

@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_leads_open_feed;
ALTER TABLE leads
DROP COLUMN IF EXISTS is_urgent,
DROP COLUMN IF EXISTS urgent_at;
COMMIT;

View file

@ -0,0 +1,21 @@
-- New paid feature: a customer can pay 50 TraceCoins to mark their own
-- open requirement ("lead", in professional-facing vocabulary) as urgent.
-- Urgent leads: sort first in the professional-facing feed, get a raised
-- request cap (20 vs the normal 10), and trigger an immediate notification
-- to matching professionals -- see apps/customers/src/handlers.rs
-- (mark_requirement_urgent) and crates/contracts/src/profession_shared.rs
-- (the new open-leads feed + send_lead_request's cap check).
--
-- No separate expiry: urgent status lasts exactly as long as the lead
-- itself (the existing 7-day expires_at) -- it doesn't extend anything,
-- it just changes how the lead is surfaced and capped while it's live.
BEGIN;
ALTER TABLE leads
ADD COLUMN IF NOT EXISTS is_urgent BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS urgent_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_leads_open_feed
ON leads (profession_key, status, is_urgent DESC, created_at DESC);
COMMIT;

View file

@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE lead_requests
DROP CONSTRAINT IF EXISTS lead_requests_lead_id_fkey,
DROP CONSTRAINT IF EXISTS lead_requests_user_role_profile_id_fkey;
COMMIT;

View file

@ -0,0 +1,29 @@
-- lead_requests.lead_id and .user_role_profile_id were plain UUID columns
-- with no DB-level FK to leads(id) / user_role_profiles(id) -- app code
-- always populates them correctly today, but nothing stops a future bug
-- (or a bad migration/backfill) from writing a dangling reference, and
-- Postgres can't catch it without the constraint. professional_user_id and
-- customer_user_id already reference users(id); this brings the other two
-- foreign keys up to the same level of enforcement.
--
-- Safe to run live: fails loudly rather than silently if any row already
-- has a dangling reference, same reasoning as the job_applications unique
-- constraint migration.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql on every deploy, so every migration must
-- tolerate being run more than once.
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_lead_id_fkey') THEN
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_lead_id_fkey FOREIGN KEY (lead_id) REFERENCES leads(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_user_role_profile_id_fkey') THEN
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_user_role_profile_id_fkey FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id);
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE users
DROP COLUMN IF EXISTS first_name,
DROP COLUMN IF EXISTS last_name;
COMMIT;

View file

@ -0,0 +1,40 @@
-- Root cause of a live bug: commit a3076ed ("update DB schema - split
-- users.first_name, users.last_name, roles split", 2026-04-15) rewrote
-- ~35 files across the codebase -- UserRepository::create/get_*, and read
-- sites throughout apps/users, apps/companies, apps/job_seekers,
-- apps/customers, crates/contracts -- to use users.first_name/last_name
-- instead of users.full_name. It never added the migration to actually
-- create those columns. users.create (the only INSERT into this table,
-- and the live registration path) has been trying to insert into
-- first_name/last_name ever since, which errors outright -- confirmed by
-- replaying every migration in this directory (the exact set baked into
-- the deployed nxtgauge-db-migrate image, see Dockerfile.migrate) against
-- a clean Postgres and attempting the same INSERT UserRepository::create
-- issues: `column "first_name" of relation "users" does not exist`.
--
-- This migration finishes what that commit should have done: add the
-- columns, backfill from the pre-existing full_name (naive split on the
-- first space -- good enough for a one-time backfill, not meant to handle
-- every name format perfectly). full_name itself is NOT dropped: other
-- code (apps/payments -- billing/invoice legal names, job_seeker_profiles)
-- still correctly reads it, so the accompanying fix to
-- UserRepository::create also writes full_name going forward so both
-- stay populated for any given user, not just whichever one happened to
-- be written.
BEGIN;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255);
UPDATE users
SET
first_name = COALESCE(first_name, NULLIF(split_part(full_name, ' ', 1), '')),
last_name = COALESCE(
last_name,
NULLIF(trim(substring(full_name FROM length(split_part(full_name, ' ', 1)) + 1)), '')
)
WHERE full_name IS NOT NULL
AND (first_name IS NULL OR last_name IS NULL);
COMMIT;

View file

@ -0,0 +1,11 @@
BEGIN;
DROP TABLE IF EXISTS ai_subscription_history;
ALTER TABLE user_ai_subscriptions
DROP COLUMN IF EXISTS downgrade_scheduled_to,
DROP COLUMN IF EXISTS is_trial,
DROP COLUMN IF EXISTS trial_days,
DROP COLUMN IF EXISTS trial_ends_at;
COMMIT;

View file

@ -0,0 +1,61 @@
-- apps/users/src/ai_subscription.rs (upgrade_plan, schedule_downgrade,
-- apply_scheduled_downgrades, cancel_subscription, start_trial,
-- expire_trials, get_subscription_history -- the whole plan-change/trial
-- lifecycle for AI credits) and the matching cron tasks
-- (apps/cron/src/tasks/ai_credits.rs::apply_scheduled_downgrades/
-- expire_trials) were written entirely against columns and a table that
-- were never actually migrated:
-- - user_ai_subscriptions.downgrade_scheduled_to / is_trial / trial_days
-- / trial_ends_at don't exist
-- - ai_subscription_history doesn't exist as a table at all
--
-- Confirmed live: apply_scheduled_downgrades and expire_trials have been
-- failing on every single hourly cron run ("column ... does not exist").
-- Any user hitting upgrade/downgrade/cancel/trial-start through
-- apps/users/src/ai_subscription.rs would 500 the same way.
--
-- Schema below is reconstructed from every INSERT/UPDATE/SELECT against
-- these in ai_subscription.rs -- not a guess: from_plan_id is nullable
-- (upgrade_plan's LEFT JOIN in get_subscription_history, and the column
-- is never NOT NULL in any INSERT), to_plan_id is always bound and
-- INNER JOINed so NOT NULL, proration_credits/proration_days_remaining
-- are only bound by upgrade_plan (every other call site omits them, so
-- they need defaults), created_by is only bound by upgrade_plan/
-- schedule_downgrade/cancel_subscription (expire_trials omits it, so
-- nullable).
BEGIN;
ALTER TABLE user_ai_subscriptions
ADD COLUMN IF NOT EXISTS downgrade_scheduled_to UUID REFERENCES ai_plans(id),
ADD COLUMN IF NOT EXISTS is_trial BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS trial_days INTEGER,
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS ai_subscription_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_plan_id UUID REFERENCES ai_plans(id),
to_plan_id UUID NOT NULL REFERENCES ai_plans(id),
change_type VARCHAR(20) NOT NULL,
proration_credits INTEGER NOT NULL DEFAULT 0,
proration_days_remaining INTEGER,
effective_at TIMESTAMPTZ NOT NULL,
created_by UUID REFERENCES users(id),
status VARCHAR(20) NOT NULL DEFAULT 'completed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_user
ON ai_subscription_history(user_id, created_at DESC);
-- apply_scheduled_downgrades' UPDATE closes out the matching 'scheduled'
-- row by (user_id, change_type, status) with no id to key off of --
-- guard against ever matching more than one in-flight scheduled downgrade
-- per user at a time (schedule_downgrade itself has no such guard either,
-- but this index at least makes the lookup correct/fast and documents the
-- assumption).
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_pending_downgrade
ON ai_subscription_history(user_id, change_type, status)
WHERE change_type = 'downgrade' AND status = 'scheduled';
COMMIT;

View file

@ -41,7 +41,7 @@ impl CateringServiceRepository {
csp.price_per_head_inr, csp.created_at, csp.updated_at csp.price_per_head_inr, csp.created_at, csp.updated_at
FROM catering_service_profiles csp FROM catering_service_profiles csp
INNER JOIN user_role_profiles urp ON urp.id = csp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = csp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'catering_service'"#, WHERE urp.user_id = $1 AND urp.role_key = 'CATERING_SERVICES'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -50,7 +50,7 @@ impl CateringServiceRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result<CateringServiceProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'catering_service'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'CATERING_SERVICES'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -67,7 +67,7 @@ impl CustomerRepository {
INSERT INTO customer_profiles ( INSERT INTO customer_profiles (
user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING') VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING')
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
first_name = EXCLUDED.first_name, first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name, last_name = EXCLUDED.last_name,

View file

@ -35,7 +35,7 @@ impl DeveloperRepository {
dp.created_at, dp.updated_at dp.created_at, dp.updated_at
FROM developer_profiles dp FROM developer_profiles dp
INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'developer'"#, WHERE urp.user_id = $1 AND urp.role_key = 'DEVELOPER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -44,7 +44,7 @@ impl DeveloperRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result<DeveloperProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'developer'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'DEVELOPER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -52,9 +52,9 @@ impl DeveloperRepository {
.ok_or(sqlx::Error::RowNotFound)?; .ok_or(sqlx::Error::RowNotFound)?;
sqlx::query_as::<_, DeveloperProfile>( sqlx::query_as::<_, DeveloperProfile>(
r#"INSERT INTO developer_profiles (user_role_profile_id, tech_stack, experience_years, r#"INSERT INTO developer_profiles (user_id, user_role_profile_id, tech_stack, experience_years,
availability, hourly_rate_inr, remote_ok) availability, hourly_rate_inr, remote_ok)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_role_profile_id) DO UPDATE SET ON CONFLICT (user_role_profile_id) DO UPDATE SET
tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack), tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack),
experience_years = EXCLUDED.experience_years, experience_years = EXCLUDED.experience_years,
@ -65,6 +65,7 @@ impl DeveloperRepository {
RETURNING id, user_role_profile_id, tech_stack, experience_years, availability, RETURNING id, user_role_profile_id, tech_stack, experience_years, availability,
hourly_rate_inr, remote_ok, created_at, updated_at"#, hourly_rate_inr, remote_ok, created_at, updated_at"#,
) )
.bind(user_id)
.bind(user_role_profile.0) .bind(user_role_profile.0)
.bind(&p.tech_stack) .bind(&p.tech_stack)
.bind(p.experience_years) .bind(p.experience_years)

View file

@ -37,7 +37,7 @@ impl FitnessTrainerRepository {
ftp.created_at, ftp.updated_at ftp.created_at, ftp.updated_at
FROM fitness_trainer_profiles ftp FROM fitness_trainer_profiles ftp
INNER JOIN user_role_profiles urp ON urp.id = ftp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = ftp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'fitness_trainer'"#, WHERE urp.user_id = $1 AND urp.role_key = 'FITNESS_TRAINER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -46,7 +46,7 @@ impl FitnessTrainerRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result<FitnessTrainerProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'fitness_trainer'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'FITNESS_TRAINER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -33,7 +33,7 @@ impl GraphicDesignerRepository {
gdp.created_at, gdp.updated_at gdp.created_at, gdp.updated_at
FROM graphic_designer_profiles gdp FROM graphic_designer_profiles gdp
INNER JOIN user_role_profiles urp ON urp.id = gdp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = gdp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'graphic_designer'"#, WHERE urp.user_id = $1 AND urp.role_key = 'GRAPHIC_DESIGNER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -42,7 +42,7 @@ impl GraphicDesignerRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result<GraphicDesignerProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'graphic_designer'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'GRAPHIC_DESIGNER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -123,4 +123,43 @@ impl LeadRequestRepository {
Ok(req) Ok(req)
} }
/// Transitions status only if the row is currently `from_status`,
/// atomically -- the `WHERE status = $3` makes this safe to call from
/// two concurrent requests racing the same lead_request (e.g. approve
/// racing reject, or a double-click on approve): only one call's
/// `UPDATE` matches a row and gets `Some(..)` back, the other gets
/// `None` because by the time its `UPDATE` runs the row's status has
/// already moved. Callers previously read status in a separate
/// `SELECT`, branched in Rust, then called the unconditional
/// `update_status` above with no re-check -- the same check-then-act
/// shape as the job_applications duplicate-application bug.
///
/// Also used to compensate: if a wallet debit/release fails after this
/// transitions PENDING -> ACCEPTED/REJECTED, callers revert with
/// `update_status_from(pool, id, "ACCEPTED", "PENDING")` so a failed
/// debit doesn't leave the lead stuck resolved with no Tracecoins
/// actually moved.
pub async fn update_status_from(
pool: &PgPool,
id: Uuid,
from_status: &str,
to_status: &str,
) -> Result<Option<LeadRequest>, sqlx::Error> {
let req = sqlx::query_as::<_, LeadRequest>(
r#"
UPDATE lead_requests
SET status = $1, resolved_at = NOW(), updated_at = NOW()
WHERE id = $2 AND status = $3
RETURNING *
"#,
)
.bind(to_status)
.bind(id)
.bind(from_status)
.fetch_optional(pool)
.await?;
Ok(req)
}
} }

View file

@ -35,7 +35,7 @@ impl MakeupArtistRepository {
map.created_at, map.updated_at map.created_at, map.updated_at
FROM makeup_artist_profiles map FROM makeup_artist_profiles map
INNER JOIN user_role_profiles urp ON urp.id = map.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = map.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'makeup_artist'"#, WHERE urp.user_id = $1 AND urp.role_key = 'MAKEUP_ARTIST'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -44,7 +44,7 @@ impl MakeupArtistRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result<MakeupArtistProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'makeup_artist'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'MAKEUP_ARTIST'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -92,7 +92,13 @@ pub struct UpdatePortfolioItemPayload {
pub struct CreateServicePayload { pub struct CreateServicePayload {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
/// Accepts `price` or `price_inr` (frontend alias). Stored as integer paise/rupees.
#[serde(alias = "price_inr")]
pub price: i32, pub price: i32,
/// Accepts `duration_minutes` or `duration_hours` (frontend alias).
/// When `duration_hours` is used the value is multiplied by 60 on the frontend
/// side to convert to minutes; stored in minutes.
#[serde(alias = "duration_hours")]
pub duration_minutes: Option<i32>, pub duration_minutes: Option<i32>,
} }
@ -100,7 +106,11 @@ pub struct CreateServicePayload {
pub struct UpdateServicePayload { pub struct UpdateServicePayload {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
/// Accepts `price` or `price_inr` (frontend alias).
#[serde(alias = "price_inr")]
pub price: Option<i32>, pub price: Option<i32>,
/// Accepts `duration_minutes` or `duration_hours` (frontend alias).
#[serde(alias = "duration_hours")]
pub duration_minutes: Option<i32>, pub duration_minutes: Option<i32>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
} }
@ -383,172 +393,21 @@ impl ProfessionalRepository {
.await .await
} }
pub async fn try_reserve_tracecoins( // try_reserve_tracecoins / try_debit_reserved_tracecoins /
pool: &PgPool, // try_release_reserved_tracecoins used to live here as a second,
user_id: Uuid, // independently-written copy of what
amount: i32, // db::models::tracecoin_wallet::TracecoinWalletRepository already does
reference_id: Uuid, // correctly -- and this copy's ledger INSERTs used `type`/`reason`
) -> Result<bool, sqlx::Error> { // columns that don't exist on tracecoin_ledger (it's `transaction_type`/
let mut tx = pool.begin().await?; // `reference_type`), so any call into it would have errored at the DB
// layer. Confirmed via a repo-wide grep that nothing called
sqlx::query( // `ProfessionalRepository::try_*` -- every real caller (profession_
r#" // shared.rs, customers/handlers.rs) already goes through
INSERT INTO tracecoin_wallets (user_id, balance, reserved) // TracecoinWalletRepository -- so this was dead code and a landmine
VALUES ($1, 0, 0) // rather than a working alternative. Removed instead of fixed in place,
ON CONFLICT (user_id) DO NOTHING // since a second implementation of the same reserve/debit/release
"#, // semantics is itself the problem: it's one more place to keep in sync
) // with the schema and with TracecoinWalletRepository's actual behavior.
.bind(user_id)
.execute(&mut *tx)
.await?;
let wallet = sqlx::query_as::<_, Wallet>(
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
)
.bind(user_id)
.fetch_one(&mut *tx)
.await?;
if wallet.balance < amount {
tx.rollback().await?;
return Ok(false);
}
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET balance = balance - $1, reserved = reserved + $1, updated_at = NOW()
WHERE id = $2
"#,
)
.bind(amount)
.bind(wallet.id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
VALUES ($1, 'RESERVE', $2, 'LEAD_REQUEST', $3)
"#,
)
.bind(wallet.id)
.bind(amount)
.bind(reference_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(true)
}
pub async fn try_debit_reserved_tracecoins(
pool: &PgPool,
user_id: Uuid,
amount: i32,
reference_id: Uuid,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
let wallet = sqlx::query_as::<_, Wallet>(
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let Some(wallet) = wallet else {
tx.rollback().await?;
return Ok(false);
};
if wallet.reserved < amount {
tx.rollback().await?;
return Ok(false);
}
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET reserved = reserved - $1, updated_at = NOW()
WHERE id = $2
"#,
)
.bind(amount)
.bind(wallet.id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
VALUES ($1, 'DEBIT', $2, 'LEAD_ACCEPTED', $3)
"#,
)
.bind(wallet.id)
.bind(amount)
.bind(reference_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(true)
}
pub async fn try_release_reserved_tracecoins(
pool: &PgPool,
user_id: Uuid,
amount: i32,
reference_id: Uuid,
reason: &str,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
let wallet = sqlx::query_as::<_, Wallet>(
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let Some(wallet) = wallet else {
tx.rollback().await?;
return Ok(false);
};
if wallet.reserved < amount {
tx.rollback().await?;
return Ok(false);
}
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET reserved = reserved - $1, balance = balance + $1, updated_at = NOW()
WHERE id = $2
"#,
)
.bind(amount)
.bind(wallet.id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id)
VALUES ($1, 'RELEASE', $2, $3, $4)
"#,
)
.bind(wallet.id)
.bind(amount)
.bind(reason)
.bind(reference_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(true)
}
pub async fn submit_for_verification( pub async fn submit_for_verification(
pool: &PgPool, pool: &PgPool,

View file

@ -17,6 +17,8 @@ pub struct Requirement {
pub rejection_reason: Option<String>, pub rejection_reason: Option<String>,
pub request_count: i32, pub request_count: i32,
pub accepted_count: i32, pub accepted_count: i32,
pub is_urgent: bool,
pub urgent_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>, pub expires_at: Option<DateTime<Utc>>,
pub approved_at: Option<DateTime<Utc>>, pub approved_at: Option<DateTime<Utc>>,
pub approved_by: Option<Uuid>, pub approved_by: Option<Uuid>,

View file

@ -35,7 +35,7 @@ impl SocialMediaManagerRepository {
smmp.created_at, smmp.updated_at smmp.created_at, smmp.updated_at
FROM social_media_manager_profiles smmp FROM social_media_manager_profiles smmp
INNER JOIN user_role_profiles urp ON urp.id = smmp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = smmp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'social_media_manager'"#, WHERE urp.user_id = $1 AND urp.role_key = 'SOCIAL_MEDIA_MANAGER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -44,7 +44,7 @@ impl SocialMediaManagerRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result<SocialMediaManagerProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'social_media_manager'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'SOCIAL_MEDIA_MANAGER'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -1,8 +1,14 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool, Postgres, Transaction};
use uuid::Uuid; use uuid::Uuid;
/// How long a purchased Tracecoin bucket stays spendable before it expires.
/// Checklist rule: "Purchased credits valid for 3 months." Only applies to
/// real purchases (see `create_purchase_bucket`'s callers) -- admin-granted
/// adjustments never create a bucket, so they never expire.
pub const PURCHASE_VALIDITY_DAYS: i64 = 90;
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Wallet { pub struct Wallet {
pub id: Uuid, pub id: Uuid,
@ -25,9 +31,93 @@ pub struct LedgerEntry {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, FromRow)]
struct BucketRow {
id: Uuid,
amount_remaining: i32,
}
pub struct TracecoinWalletRepository; pub struct TracecoinWalletRepository;
impl TracecoinWalletRepository { impl TracecoinWalletRepository {
/// Records a real purchase as a FIFO-consumable bucket with a 3-month
/// expiry, in the same transaction as the wallet credit. This is what
/// makes "purchased credits expire in 3 months" possible at all --
/// `tracecoin_wallets.balance` is a flat number with no purchase-date
/// tracking on its own; `tracecoin_buckets` is where that history
/// lives. Only call this for an actual purchase (currently just
/// apps/payments/src/reconcile.rs's PayU credit path) -- admin_adjust
/// and anything else that credits the wallet without a real payment
/// behind it should NOT create a bucket, since checklist rule is
/// "purchased credits", not all credits.
pub async fn create_purchase_bucket(
tx: &mut Transaction<'_, Postgres>,
user_id: Uuid,
amount: i32,
source: &str,
reference_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO tracecoin_buckets (user_id, source, amount, amount_remaining, reference_id, source_kind, expires_at)
VALUES ($1, $2, $3, $3, $4, 'PURCHASE', NOW() + ($5 || ' days')::interval)
ON CONFLICT (reference_id) WHERE reference_id IS NOT NULL DO NOTHING
"#,
)
.bind(user_id)
.bind(source)
.bind(amount)
.bind(reference_id)
.bind(PURCHASE_VALIDITY_DAYS.to_string())
.execute(&mut **tx)
.await?;
Ok(())
}
/// Draws `amount` down from `user_id`'s purchase buckets, oldest
/// (soonest-to-expire) first, without erroring if buckets don't cover
/// the whole amount -- any shortfall is coming from a non-bucket-
/// tracked credit (e.g. an admin grant), which is fine; buckets only
/// exist to know how much of a *purchase* is still unspent when it
/// expires, not to gate spending itself (the wallet's real `balance`
/// already does that). Call this from the same transaction as any
/// call site that permanently debits `balance` for a real spend --
/// NOT from a reserve or release, since no money has actually left the
/// wallet yet at that point.
async fn consume_purchase_buckets_fifo(
tx: &mut Transaction<'_, Postgres>,
user_id: Uuid,
amount: i32,
) -> Result<(), sqlx::Error> {
let mut remaining = amount;
let buckets = sqlx::query_as::<_, BucketRow>(
r#"
SELECT id, amount_remaining
FROM tracecoin_buckets
WHERE user_id = $1 AND amount_remaining > 0
ORDER BY expires_at ASC NULLS LAST, created_at ASC
FOR UPDATE
"#,
)
.bind(user_id)
.fetch_all(&mut **tx)
.await?;
for bucket in buckets {
if remaining <= 0 {
break;
}
let draw = remaining.min(bucket.amount_remaining);
sqlx::query("UPDATE tracecoin_buckets SET amount_remaining = amount_remaining - $1 WHERE id = $2")
.bind(draw)
.bind(bucket.id)
.execute(&mut **tx)
.await?;
remaining -= draw;
}
Ok(())
}
pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Wallet, sqlx::Error> { pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<Wallet, sqlx::Error> {
sqlx::query_as::<_, Wallet>( sqlx::query_as::<_, Wallet>(
"SELECT * FROM tracecoin_wallets WHERE user_id = $1", "SELECT * FROM tracecoin_wallets WHERE user_id = $1",
@ -110,6 +200,86 @@ impl TracecoinWalletRepository {
Ok(true) Ok(true)
} }
/// Debits `amount` directly from `balance` (not via reserve/confirm) --
/// for one-shot spends like the urgent-lead upgrade, where there's no
/// multi-step hold to manage: check funds, spend, done. Idempotent on
/// `reference_id` + `reference_type`: a retry with the same reference
/// finds the existing ledger row and returns `true` without debiting
/// twice, mirroring the FOR-UPDATE-gated pattern the reserve/debit/
/// release trio above already uses.
pub async fn try_debit_balance(
pool: &PgPool,
user_id: Uuid,
amount: i32,
reference_type: &str,
reference_id: Uuid,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query(
r#"
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, 0, 0)
ON CONFLICT (user_id) DO NOTHING
"#,
)
.bind(user_id)
.execute(&mut *tx)
.await?;
let wallet = sqlx::query_as::<_, Wallet>(
"SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE",
)
.bind(user_id)
.fetch_one(&mut *tx)
.await?;
let already: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM tracecoin_ledger WHERE wallet_id = $1 AND reference_id = $2 AND reference_type = $3",
)
.bind(wallet.id)
.bind(reference_id)
.bind(reference_type)
.fetch_optional(&mut *tx)
.await?;
if already.is_some() {
tx.commit().await?;
return Ok(true);
}
if wallet.balance < amount {
tx.rollback().await?;
return Ok(false);
}
sqlx::query(
"UPDATE tracecoin_wallets SET balance = balance - $1, updated_at = NOW() WHERE id = $2",
)
.bind(amount)
.bind(wallet.id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'DEBIT', $2, $3, $4)
"#,
)
.bind(wallet.id)
.bind(amount)
.bind(reference_type)
.bind(reference_id)
.execute(&mut *tx)
.await?;
Self::consume_purchase_buckets_fifo(&mut tx, user_id, amount).await?;
tx.commit().await?;
Ok(true)
}
pub async fn try_debit_reserved_tracecoins( pub async fn try_debit_reserved_tracecoins(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
@ -159,6 +329,12 @@ impl TracecoinWalletRepository {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// This is the actual permanent spend (reserved -> gone), so this is
// the point real purchase-bucket balance gets drawn down, not at
// reserve time (reserving doesn't spend anything yet -- see
// consume_purchase_buckets_fifo's doc comment).
Self::consume_purchase_buckets_fifo(&mut tx, user_id, amount).await?;
tx.commit().await?; tx.commit().await?;
Ok(true) Ok(true)
} }

View file

@ -39,7 +39,7 @@ impl TutorRepository {
tp.created_at, tp.updated_at tp.created_at, tp.updated_at
FROM tutor_profiles tp FROM tutor_profiles tp
INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'tutor'"#, WHERE urp.user_id = $1 AND urp.role_key = 'TUTOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -48,7 +48,7 @@ impl TutorRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result<TutorProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'tutor'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'TUTOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -35,7 +35,7 @@ impl UgcContentCreatorRepository {
uccp.created_at, uccp.updated_at uccp.created_at, uccp.updated_at
FROM ugc_content_creator_profiles uccp FROM ugc_content_creator_profiles uccp
INNER JOIN user_role_profiles urp ON urp.id = uccp.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = uccp.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'ugc_content_creator'"#, WHERE urp.user_id = $1 AND urp.role_key = 'UGC_CONTENT_CREATOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -44,7 +44,7 @@ impl UgcContentCreatorRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result<UgcContentCreatorProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result<UgcContentCreatorProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'ugc_content_creator'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'UGC_CONTENT_CREATOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -51,10 +51,23 @@ pub struct UserRepository;
impl UserRepository { impl UserRepository {
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> { pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
// full_name is still the column apps/payments (invoice/billing
// legal names) and job_seeker_profiles read -- see the migration
// that added first_name/last_name for the full story. Writing it
// here too, derived from first_name/last_name, keeps both
// representations correct for every newly-created user instead of
// only whichever one a given caller happens to read.
let full_name = match (&payload.first_name, &payload.last_name) {
(Some(f), Some(l)) if !l.trim().is_empty() => Some(format!("{f} {l}")),
(Some(f), _) => Some(f.clone()),
(None, Some(l)) => Some(l.clone()),
(None, None) => None,
};
let user = sqlx::query_as::<_, User>( let user = sqlx::query_as::<_, User>(
r#" r#"
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified) INSERT INTO users (first_name, last_name, full_name, email, password_hash, email_verified, phone_verified)
VALUES ($1, $2, $3, $4, false, false) VALUES ($1, $2, $3, $4, $5, false, false)
RETURNING RETURNING
id, reference_number, email, password_hash, first_name, last_name, id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status, email_verified, phone_verified, status,
@ -65,6 +78,7 @@ impl UserRepository {
) )
.bind(&payload.first_name) .bind(&payload.first_name)
.bind(&payload.last_name) .bind(&payload.last_name)
.bind(&full_name)
.bind(payload.email.to_lowercase()) .bind(payload.email.to_lowercase())
.bind(payload.password_hash) .bind(payload.password_hash)
.fetch_one(pool) .fetch_one(pool)

View file

@ -33,7 +33,7 @@ impl VideoEditorRepository {
vep.created_at, vep.updated_at vep.created_at, vep.updated_at
FROM video_editor_profiles vep FROM video_editor_profiles vep
INNER JOIN user_role_profiles urp ON urp.id = vep.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = vep.user_role_profile_id
WHERE urp.user_id = $1 AND urp.role_key = 'video_editor'"#, WHERE urp.user_id = $1 AND urp.role_key = 'VIDEO_EDITOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
@ -42,7 +42,7 @@ impl VideoEditorRepository {
pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result<VideoEditorProfile, sqlx::Error> {
let user_role_profile = sqlx::query_as::<_, (Uuid,)>( let user_role_profile = sqlx::query_as::<_, (Uuid,)>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'video_editor'"#, r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'VIDEO_EDITOR'"#,
) )
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)

View file

@ -26,6 +26,20 @@ use uuid::Uuid;
// Tax // Tax
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
/// Standard GST rate applied to Tracecoin/AI-credit package purchases.
/// Was previously the literal `18.0` duplicated at both invoice-generation
/// call sites (apps/payments/src/main.rs and ai_credits.rs) with nothing
/// tying them together -- a rate change meant remembering to update both
/// (and any future call site). Centralized here instead.
///
/// Note this is independent of the admin-configurable `tax_rules` table
/// (apps/payments/src/admin.rs) -- that CRUD exists but nothing in
/// invoice generation reads it yet, so changing a tax rule via that API
/// currently has no effect on any invoice, historical or future. Wiring
/// that up (or removing it if it's not meant to be live yet) is a product
/// decision, not folded into this constant.
pub const STANDARD_GST_RATE_PERCENT: f64 = 18.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaxType { pub enum TaxType {
/// No tax (inter-state supply to an unregistered person — usually not /// No tax (inter-state supply to an unregistered person — usually not

View file

@ -283,7 +283,7 @@ pub async fn settle(
let ledger_id: Uuid = sqlx::query_scalar( let ledger_id: Uuid = sqlx::query_scalar(
r#" r#"
INSERT INTO tracecoin_ledger ( INSERT INTO tracecoin_ledger (
wallet_id, type, amount, balance_after, reason, wallet_id, transaction_type, amount, balance_after, reference_type,
reference_id, actor_user_id, metadata reference_id, actor_user_id, metadata
) )
VALUES ($1, 'DEBIT', $2, $3, 'HOLD_SETTLED', $4, NULL, $5) VALUES ($1, 'DEBIT', $2, $3, 'HOLD_SETTLED', $4, NULL, $5)

View file

@ -288,16 +288,16 @@ async fn write_ledger_entry(
r#" r#"
SELECT SELECT
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
created_at created_at
FROM tracecoin_ledger FROM tracecoin_ledger
WHERE wallet_id = $1 AND reference_id = $2 AND type = $3 WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = $3
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -314,16 +314,16 @@ async fn write_ledger_entry(
let row = sqlx::query( let row = sqlx::query(
r#" r#"
INSERT INTO tracecoin_ledger ( INSERT INTO tracecoin_ledger (
wallet_id, type, amount, balance_after, reason, wallet_id, transaction_type, amount, balance_after, reference_type,
reference_id, actor_user_id, metadata reference_id, actor_user_id, metadata
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING RETURNING
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
@ -382,16 +382,16 @@ pub async fn credit(
r#" r#"
SELECT SELECT
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
created_at created_at
FROM tracecoin_ledger FROM tracecoin_ledger
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'CREDIT' WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'CREDIT'
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -466,16 +466,16 @@ pub async fn reserve(
r#" r#"
SELECT SELECT
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
created_at created_at
FROM tracecoin_ledger FROM tracecoin_ledger
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RESERVE' WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'RESERVE'
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -551,16 +551,16 @@ pub async fn release(
r#" r#"
SELECT SELECT
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
created_at created_at
FROM tracecoin_ledger FROM tracecoin_ledger
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RELEASE' WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'RELEASE'
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -632,16 +632,16 @@ pub async fn confirm(
r#" r#"
SELECT SELECT
id, wallet_id, id, wallet_id,
type AS entry_type, transaction_type AS entry_type,
amount, amount,
balance_after, balance_after,
reason, reference_type AS reason,
reference_id, reference_id,
actor_user_id, actor_user_id,
metadata, metadata,
created_at created_at
FROM tracecoin_ledger FROM tracecoin_ledger
WHERE wallet_id = $1 AND reference_id = $2 AND type = 'DEBIT' WHERE wallet_id = $1 AND reference_id = $2 AND transaction_type = 'DEBIT'
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -771,10 +771,10 @@ pub async fn list_ledger(
r#" r#"
SELECT SELECT
tl.id, tl.wallet_id, tl.id, tl.wallet_id,
tl.type AS entry_type, tl.transaction_type AS entry_type,
tl.amount, tl.amount,
tl.balance_after, tl.balance_after,
tl.reason, tl.reference_type AS reason,
tl.reference_id, tl.reference_id,
tl.actor_user_id, tl.actor_user_id,
tl.metadata, tl.metadata,