diff --git a/adminapp/src/components/resourceDetailCommonFields.jsx b/adminapp/src/components/resourceDetailCommonFields.jsx new file mode 100644 index 00000000..c22224cf --- /dev/null +++ b/adminapp/src/components/resourceDetailCommonFields.jsx @@ -0,0 +1,31 @@ +import { dayjs } from "../modules/dayConfig"; +import AdminLink from "./AdminLink"; +import has from "lodash/has"; +import React from "react"; + +export default function resourceDetailCommonFields(model) { + const result = []; + if (model.id) { + result.push({ label: "ID", value: model.id }); + } + if (model.createdAt) { + result.push({ label: "Created At", value: dayjs(model.createdAt) }); + } + if (has(model, "updatedAt")) { + const value = model.updatedAt ? dayjs(model.updatedAt) : "-"; + result.push({ label: "Updated At", value }); + } + if (has(model, "createdBy")) { + const value = model.createdBy ? ( + {model.createdBy.name} + ) : ( + "-" + ); + result.push({ label: "Created By", value }); + } + if (has(model, "softDeletedAt")) { + const value = model.softDeletedAt ? dayjs(model.softDeletedAt) : ""; + result.push({ label: "Deleted At", value }); + } + return result; +} diff --git a/adminapp/src/pages/AnonMemberContactDetailPage.jsx b/adminapp/src/pages/AnonMemberContactDetailPage.jsx index eded6eb8..13a55d3d 100644 --- a/adminapp/src/pages/AnonMemberContactDetailPage.jsx +++ b/adminapp/src/pages/AnonMemberContactDetailPage.jsx @@ -4,7 +4,7 @@ import Copyable from "../components/Copyable"; import ExternalLinks from "../components/ExternalLinks"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function AnonMemberContactDetailPage() { @@ -15,8 +15,7 @@ export default function AnonMemberContactDetailPage() { apiDelete={api.destroyMemberContact} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Member", value: {model.member.name}, diff --git a/adminapp/src/pages/BankAccountDetailPage.jsx b/adminapp/src/pages/BankAccountDetailPage.jsx index ec1c4bf8..bdd5051d 100644 --- a/adminapp/src/pages/BankAccountDetailPage.jsx +++ b/adminapp/src/pages/BankAccountDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import LegalEntity from "../components/LegalEntity"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import React from "react"; @@ -12,13 +13,8 @@ export default function BankAccountDetailPage() { apiGet={api.getBankAccount} backTo={(m) => m.member.adminLink} properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Account Name", value: model.name }, - { label: "Created At", value: dayjs(model.createdAt) }, - { - label: "Deleted At", - value: model.softDeletedAt ? dayjs(model.softDeletedAt) : "", - }, { label: "Verified At", value: model.verifiedAt ? dayjs(model.verifiedAt) : "(not verified)", diff --git a/adminapp/src/pages/BookTransactionDetailPage.jsx b/adminapp/src/pages/BookTransactionDetailPage.jsx index 6fd9c71c..bc1bb87a 100644 --- a/adminapp/src/pages/BookTransactionDetailPage.jsx +++ b/adminapp/src/pages/BookTransactionDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import Money from "../shared/react/Money"; @@ -13,8 +14,7 @@ export default function BookTransactionDetailPage() { resource="book_transaction" apiGet={api.getBookTransaction} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Apply At", value: dayjs(model.applyAt) }, { label: "Amount", value: {model.amount} }, { label: "Category", value: model.associatedVendorServiceCategory?.name }, diff --git a/adminapp/src/pages/CardDetailPage.jsx b/adminapp/src/pages/CardDetailPage.jsx index 98ed6264..16b6a982 100644 --- a/adminapp/src/pages/CardDetailPage.jsx +++ b/adminapp/src/pages/CardDetailPage.jsx @@ -4,7 +4,7 @@ import ExternalLinks from "../components/ExternalLinks"; import LegalEntity from "../components/LegalEntity"; import RelatedList from "../components/RelatedList"; import ResourceDetail, { ResourceSummary } from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import Money from "../shared/react/Money"; import React from "react"; @@ -16,13 +16,8 @@ export default function CardDetailPage() { backTo={(m) => m.member.adminLink} apiGet={api.getCard} properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Account Name", value: model.name }, - { label: "Created At", value: dayjs(model.createdAt) }, - { - label: "Deleted At", - value: model.softDeletedAt ? dayjs(model.softDeletedAt) : "", - }, { label: "Brand", value: model.brand }, { label: "Last 4", value: model.last4 }, { label: "Expires", value: `${model.expMonth}/${model.expYear}` }, diff --git a/adminapp/src/pages/ChargeDetailPage.jsx b/adminapp/src/pages/ChargeDetailPage.jsx index 956d9699..ddf1b13a 100644 --- a/adminapp/src/pages/ChargeDetailPage.jsx +++ b/adminapp/src/pages/ChargeDetailPage.jsx @@ -4,7 +4,7 @@ import CommerceOrderDetailGrid from "../components/CommerceOrderDetailGrid"; import MobilityTripDetailGrid from "../components/MobilityTripDetailGrid"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import { anyMoney } from "../shared/money"; import Money from "../shared/react/Money"; @@ -17,8 +17,7 @@ export default function ChargeDetailPage() { apiGet={api.getCharge} canEdit={false} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Member", value: {model.member?.name}, diff --git a/adminapp/src/pages/EligibilityAssignmentDetailPage.jsx b/adminapp/src/pages/EligibilityAssignmentDetailPage.jsx index 454b2c64..ca67860f 100644 --- a/adminapp/src/pages/EligibilityAssignmentDetailPage.jsx +++ b/adminapp/src/pages/EligibilityAssignmentDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function EligibilityAssignmentDetailPage() { @@ -12,12 +12,7 @@ export default function EligibilityAssignmentDetailPage() { apiDelete={api.destroyEligibilityAssignment} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - model.createdBy && { - label: "Created By", - value: {model.createdBy.name}, - }, + ...resourceDetailCommonFields(model), { label: "Attribute", value: {model.attribute.label}, diff --git a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx index cfd668f4..c665e3f0 100644 --- a/adminapp/src/pages/EligibilityAttributeDetailPage.jsx +++ b/adminapp/src/pages/EligibilityAttributeDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import createRelativeUrl from "../shared/createRelativeUrl"; import React from "react"; @@ -13,8 +13,7 @@ export default function EligibilityAttributeDetailPage() { apiGet={api.getEligibilityAttribute} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Description", value: model.description }, model.parent && { diff --git a/adminapp/src/pages/EligibilityRequirementDetailPage.jsx b/adminapp/src/pages/EligibilityRequirementDetailPage.jsx index d5ddbc63..948a5c75 100644 --- a/adminapp/src/pages/EligibilityRequirementDetailPage.jsx +++ b/adminapp/src/pages/EligibilityRequirementDetailPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import createRelativeUrl from "../shared/createRelativeUrl"; import EditIcon from "@mui/icons-material/Edit"; import IconButton from "@mui/material/IconButton"; @@ -17,12 +17,7 @@ export default function EligibilityRequirementDetailPage() { apiDelete={api.destroyEligibilityRequirement} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - model.createdBy && { - label: "Created By", - value: {model.createdBy.name}, - }, + ...resourceDetailCommonFields(model), { label: "Formula", value: ( diff --git a/adminapp/src/pages/FundingTransactionDetailPage.jsx b/adminapp/src/pages/FundingTransactionDetailPage.jsx index 043a272d..61b2c501 100644 --- a/adminapp/src/pages/FundingTransactionDetailPage.jsx +++ b/adminapp/src/pages/FundingTransactionDetailPage.jsx @@ -7,7 +7,7 @@ import ExternalLinks from "../components/ExternalLinks"; import PaymentStrategyDetailGrid from "../components/PaymentStrategyDetailGrid"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import { directEditRoute } from "../modules/resourceRoutes"; import Money from "../shared/react/Money"; @@ -22,8 +22,7 @@ export default function FundingTransactionDetailPage() { model.strategy.adminLink && directEditRoute(model.strategy.adminLink) } properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Originating Payment Account", value: ( diff --git a/adminapp/src/pages/MarketingListDetailPage.jsx b/adminapp/src/pages/MarketingListDetailPage.jsx index d60489c1..d924733b 100644 --- a/adminapp/src/pages/MarketingListDetailPage.jsx +++ b/adminapp/src/pages/MarketingListDetailPage.jsx @@ -2,9 +2,9 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; -import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import { Button, CircularProgress } from "@mui/material"; import React from "react"; @@ -36,9 +36,8 @@ export default function MarketingListDetailPage() { canEdit={(r) => !r.managed} canDelete={(r) => !r.managed} apiDelete={api.destroyMarketingList} - properties={(model, replaceModel) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + properties={(model) => [ + ...resourceDetailCommonFields(model), { label: "Label", value: model.label, diff --git a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx index 184ed4fa..e2d515e0 100644 --- a/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx +++ b/adminapp/src/pages/MarketingSmsBroadcastDetailPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import { Button } from "@mui/material"; import React from "react"; @@ -15,13 +15,8 @@ export default function MarketingSmsBroadcastDetailPage() { apiGet={api.getMarketingSmsBroadcast} canEdit properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Label", value: model.label }, - { label: "Created At", value: dayjs(model.createdAt) }, - { - label: "Created By", - value: {model.createdBy?.name}, - }, { label: "Sending From", value: model.sendingNumberFormatted || "(Blank - Will not send)", diff --git a/adminapp/src/pages/MarketingSmsDispatchDetailPage.jsx b/adminapp/src/pages/MarketingSmsDispatchDetailPage.jsx index 35b7c353..47baaf13 100644 --- a/adminapp/src/pages/MarketingSmsDispatchDetailPage.jsx +++ b/adminapp/src/pages/MarketingSmsDispatchDetailPage.jsx @@ -2,9 +2,9 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ExternalLinks from "../components/ExternalLinks"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import useBusy from "../hooks/useBusy"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; -import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import { Button, CircularProgress } from "@mui/material"; import React from "react"; @@ -34,8 +34,7 @@ export default function MarketingSmsDispatchDetailPage() { resource="marketing_sms_dispatch" apiGet={api.getMarketingSmsDispatch} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Broadcast", value: ( diff --git a/adminapp/src/pages/MessageDetailPage.jsx b/adminapp/src/pages/MessageDetailPage.jsx index 025560db..f5c65172 100644 --- a/adminapp/src/pages/MessageDetailPage.jsx +++ b/adminapp/src/pages/MessageDetailPage.jsx @@ -3,6 +3,7 @@ import AdminActions from "../components/AdminActions"; import AdminLink from "../components/AdminLink"; import ExternalLinks from "../components/ExternalLinks"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import { Typography } from "@mui/material"; import Box from "@mui/material/Box"; @@ -14,7 +15,7 @@ export default function MessageDetailPage() { resource="message_delivery" apiGet={api.getMessageDelivery} properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Template", value: model.template }, { label: "Transport", @@ -22,7 +23,6 @@ export default function MessageDetailPage() { }, { label: "MessageId", value: model.transportMessageId }, { label: "Template", value: model.template }, - { label: "CreatedAt", value: formatDate(model.createdAt) }, { label: "SentAt", value: formatDate(model.sentAt) }, { label: "AbortedAt", value: formatDate(model.abortedAt) }, model.recipient && { diff --git a/adminapp/src/pages/MobilityTripDetailPage.jsx b/adminapp/src/pages/MobilityTripDetailPage.jsx index 089581d9..1c3b2444 100644 --- a/adminapp/src/pages/MobilityTripDetailPage.jsx +++ b/adminapp/src/pages/MobilityTripDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ChargeDetailGrid from "../components/ChargeDetailGrid"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import React from "react"; @@ -13,8 +13,7 @@ export default function MobilityTripDetailPage() { apiGet={api.getMobilityTrip} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Member", value: {model.member?.name}, diff --git a/adminapp/src/pages/OfferingDetailPage.jsx b/adminapp/src/pages/OfferingDetailPage.jsx index a2d4a536..adc01b3a 100644 --- a/adminapp/src/pages/OfferingDetailPage.jsx +++ b/adminapp/src/pages/OfferingDetailPage.jsx @@ -6,6 +6,7 @@ import Programs from "../components/Programs"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import oneLineAddress from "../modules/oneLineAddress"; @@ -24,8 +25,7 @@ export default function OfferingDetailPage() { apiGet={api.getCommerceOffering} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), ...detailPageImageProperties(model.image), { label: "Description (En)", value: model.description.en }, { label: "Description (Es)", value: model.description.es }, diff --git a/adminapp/src/pages/OfferingProductDetailPage.jsx b/adminapp/src/pages/OfferingProductDetailPage.jsx index e4566e9a..45117e06 100644 --- a/adminapp/src/pages/OfferingProductDetailPage.jsx +++ b/adminapp/src/pages/OfferingProductDetailPage.jsx @@ -4,6 +4,7 @@ import BackTo from "../components/BackTo"; import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import { resourceEditRoute } from "../modules/resourceRoutes"; @@ -20,8 +21,7 @@ export default function OfferingProductDetailPage() { backTo={BackTo.BACK} apiSoftDelete={api.closeCommerceOfferingProduct} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Closed At", value: model.closedAt && dayjs(model.closedAt) }, { label: "Offering", diff --git a/adminapp/src/pages/OrderDetailPage.jsx b/adminapp/src/pages/OrderDetailPage.jsx index cc590530..934b7126 100644 --- a/adminapp/src/pages/OrderDetailPage.jsx +++ b/adminapp/src/pages/OrderDetailPage.jsx @@ -5,7 +5,7 @@ import ChargeDetailGrid from "../components/ChargeDetailGrid"; import DetailGrid from "../components/DetailGrid"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; import React from "react"; @@ -17,7 +17,7 @@ export default function OrderDetailPage() { apiGet={api.getCommerceOrder} canEdit={false} properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Member", value: ( @@ -26,7 +26,6 @@ export default function OrderDetailPage() { ), }, - { label: "Created At", value: dayjs(model.createdAt) }, { label: "Status", value: model.statusLabel, diff --git a/adminapp/src/pages/OrganizationDetailPage.jsx b/adminapp/src/pages/OrganizationDetailPage.jsx index dc57dae0..18280d13 100644 --- a/adminapp/src/pages/OrganizationDetailPage.jsx +++ b/adminapp/src/pages/OrganizationDetailPage.jsx @@ -8,7 +8,7 @@ import { } from "../components/OrganizationMembership"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import createRelativeUrl from "../shared/createRelativeUrl"; import { Chip } from "@mui/material"; @@ -21,9 +21,7 @@ export default function OrganizationDetailPage() { apiGet={api.getOrganization} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - { label: "Updated At", value: dayjs(model.updatedAt) }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Ordinal", value: model.ordinal }, { label: "Verification Email", value: model.membershipVerificationEmail }, diff --git a/adminapp/src/pages/OrganizationMembershipDetailPage.jsx b/adminapp/src/pages/OrganizationMembershipDetailPage.jsx index a5269a62..e7358cbe 100644 --- a/adminapp/src/pages/OrganizationMembershipDetailPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipDetailPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import AuditActivityList from "../components/AuditActivityList"; import OrganizationMembership from "../components/OrganizationMembership"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function OrganizationMembershipDetailPage() { @@ -13,9 +13,7 @@ export default function OrganizationMembershipDetailPage() { apiGet={api.getOrganizationMembership} canEdit={(model) => !model.formerOrganization} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - { label: "Updated At", value: dayjs(model.updatedAt) }, + ...resourceDetailCommonFields(model), { label: "Member", value: ( diff --git a/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx b/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx index 10929de0..d48300e8 100644 --- a/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx +++ b/adminapp/src/pages/OrganizationMembershipVerificationDetailPage.jsx @@ -5,8 +5,8 @@ import InlineEditField from "../components/InlineEditField"; import OrganizationMembership from "../components/OrganizationMembership"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import useErrorSnackbar from "../hooks/useErrorSnackbar"; -import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import membershipVerificationDuplicateRiskColor from "../modules/membershipVerificationDuplicateRiskColor"; import oneLineAddress from "../modules/oneLineAddress"; @@ -47,9 +47,7 @@ export default function OrganizationMembershipVerificationDetailPage() { apiGet={api.getOrganizationMembershipVerification} canEdit properties={(model, setModel) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - { label: "Updated At", value: dayjs(model.updatedAt) }, + ...resourceDetailCommonFields(model), { label: "Status", value: model.status }, { label: "Member", diff --git a/adminapp/src/pages/PaymentLedgerDetailPage.jsx b/adminapp/src/pages/PaymentLedgerDetailPage.jsx index 943c55a9..fa4da477 100644 --- a/adminapp/src/pages/PaymentLedgerDetailPage.jsx +++ b/adminapp/src/pages/PaymentLedgerDetailPage.jsx @@ -3,7 +3,7 @@ import AdminLink from "../components/AdminLink"; import LedgerBookTransactionsRelatedList from "../components/LedgerBookTransactionRelatedList"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; import React from "react"; @@ -13,8 +13,7 @@ export default function PaymentLedgerDetailPage() { resource="ledger" apiGet={api.getPaymentLedger} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Currency", value: model.currency }, { label: "Balance", value: {model.balance} }, diff --git a/adminapp/src/pages/PaymentTriggerDetailPage.jsx b/adminapp/src/pages/PaymentTriggerDetailPage.jsx index 19950ea8..cb79cbfb 100644 --- a/adminapp/src/pages/PaymentTriggerDetailPage.jsx +++ b/adminapp/src/pages/PaymentTriggerDetailPage.jsx @@ -5,6 +5,7 @@ import EligibilityRequirementsRelatedList from "../components/EligibilityRequire import Link from "../components/Link"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import { formatMoney, intToMoney } from "../shared/money"; @@ -21,10 +22,8 @@ export default function PaymentTriggerDetailPage() { apiGet={api.getPaymentTrigger} canEdit properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Label", value: model.label }, - { label: "Created At", value: dayjs(model.createdAt) }, - { label: "Updated At", value: dayjs(model.updatedAt) }, { label: "Starting", value: dayjs(model.activeDuringBegin) }, { label: "Ending", value: dayjs(model.activeDuringEnd) }, { @@ -45,11 +44,11 @@ export default function PaymentTriggerDetailPage() { { label: "Match Percentage", value: Math.round(model.matchFraction * 100) + "%" }, { label: "Unmatched Amount", - value: formatMoney(intToMoney(model.unmatchedAmountCents)), + value: formatMoney(intToMoney(model.unmatchedAmountCents, "USD")), }, { label: "Max Subsidy", - value: formatMoney(intToMoney(model.maximumCumulativeSubsidyCents)), + value: formatMoney(intToMoney(model.maximumCumulativeSubsidyCents, "USD")), }, { label: "Act as Credit", value: model.actAsCredit }, { label: "Memo (En)", value: model.memo.en }, diff --git a/adminapp/src/pages/PayoutTransactionDetailPage.jsx b/adminapp/src/pages/PayoutTransactionDetailPage.jsx index 21bc0004..5c6a2d6c 100644 --- a/adminapp/src/pages/PayoutTransactionDetailPage.jsx +++ b/adminapp/src/pages/PayoutTransactionDetailPage.jsx @@ -6,6 +6,7 @@ import DetailGrid from "../components/DetailGrid"; import ExternalLinks from "../components/ExternalLinks"; import PaymentStrategyDetailGrid from "../components/PaymentStrategyDetailGrid"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import { directEditRoute } from "../modules/resourceRoutes"; import Money from "../shared/react/Money"; @@ -20,8 +21,7 @@ export default function PayoutTransactionDetailPage() { model.strategy.adminLink && directEditRoute(model.strategy.adminLink) } properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Status", value: model.status }, { label: "Amount", value: {model.amount} }, { label: "Classification", value: model.classification }, diff --git a/adminapp/src/pages/ProductDetailPage.jsx b/adminapp/src/pages/ProductDetailPage.jsx index 91be0701..5261e403 100644 --- a/adminapp/src/pages/ProductDetailPage.jsx +++ b/adminapp/src/pages/ProductDetailPage.jsx @@ -4,6 +4,7 @@ import CategoriesRelatedList from "../components/CategoriesRelatedList"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import createRelativeUrl from "../shared/createRelativeUrl"; @@ -17,8 +18,7 @@ export default function ProductDetailPage() { apiGet={api.getCommerceProduct} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), ...detailPageImageProperties(model.image), { label: "Name (En)", value: model.name.en }, { label: "Name (Es)", value: model.name.es }, diff --git a/adminapp/src/pages/ProgramDetailPage.jsx b/adminapp/src/pages/ProgramDetailPage.jsx index 99520f58..f6e75219 100644 --- a/adminapp/src/pages/ProgramDetailPage.jsx +++ b/adminapp/src/pages/ProgramDetailPage.jsx @@ -5,6 +5,7 @@ import EligibilityRequirementsRelatedList from "../components/EligibilityRequire import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import formatDate from "../modules/formatDate"; import createRelativeUrl from "../shared/createRelativeUrl"; @@ -18,8 +19,7 @@ export default function ProgramDetailPage() { apiGet={api.getProgram} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), ...detailPageImageProperties(model.image), { label: "Name EN", value: model.name.en }, { label: "Name ES", value: model.name.es }, diff --git a/adminapp/src/pages/ProgramPricingDetailPage.jsx b/adminapp/src/pages/ProgramPricingDetailPage.jsx index 6e3287fc..f95681a0 100644 --- a/adminapp/src/pages/ProgramPricingDetailPage.jsx +++ b/adminapp/src/pages/ProgramPricingDetailPage.jsx @@ -1,7 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function ProgramPricingDetailPage() { @@ -13,9 +13,7 @@ export default function ProgramPricingDetailPage() { apiGet={api.getProgramPricing} apiDelete={api.destroyProgramPricing} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, - { label: "Updated At", value: dayjs(model.updatedAt) }, + ...resourceDetailCommonFields(model), { label: "Program", value: {model.program.name.en}, diff --git a/adminapp/src/pages/RegistrationLinkDetailPage.jsx b/adminapp/src/pages/RegistrationLinkDetailPage.jsx index 0e61c42c..c648041a 100644 --- a/adminapp/src/pages/RegistrationLinkDetailPage.jsx +++ b/adminapp/src/pages/RegistrationLinkDetailPage.jsx @@ -3,6 +3,7 @@ import AdminLink from "../components/AdminLink"; import Copyable from "../components/Copyable"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import React from "react"; @@ -14,10 +15,7 @@ export default function RegistrationLinkDetailPage() { canEdit apiDelete={api.destroyOrganizationRegistrationLink} properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: formatDate(model.createdAt) }, - { label: "Updated At", value: formatDate(model.updatedAt) }, - { label: "Created By", value: }, + ...resourceDetailCommonFields(model), { label: "Organization", value: }, { label: "Intro EN", value: model.intro.en }, { label: "Intro ES", value: model.intro.es }, diff --git a/adminapp/src/pages/RoleDetailPage.jsx b/adminapp/src/pages/RoleDetailPage.jsx index 250be320..f5ffe023 100644 --- a/adminapp/src/pages/RoleDetailPage.jsx +++ b/adminapp/src/pages/RoleDetailPage.jsx @@ -3,6 +3,7 @@ import AdminLink from "../components/AdminLink"; import EligibilityAssignmentsRelatedList from "../components/EligibilityAssignmentsRelatedList"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function RoleDetailPage() { @@ -11,7 +12,7 @@ export default function RoleDetailPage() { resource="role" apiGet={api.getRole} properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Description", value: model.description }, ]} diff --git a/adminapp/src/pages/VendorAccountDetailPage.jsx b/adminapp/src/pages/VendorAccountDetailPage.jsx index ba105d35..d613b16f 100644 --- a/adminapp/src/pages/VendorAccountDetailPage.jsx +++ b/adminapp/src/pages/VendorAccountDetailPage.jsx @@ -5,9 +5,11 @@ import BoolCheckmark from "../components/BoolCheckmark"; import DetailGrid from "../components/DetailGrid"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import SafeExternalLink from "../shared/react/SafeExternalLink"; +import { Typography } from "@mui/material"; +import TableCell from "@mui/material/TableCell"; import React from "react"; export default function VendorAccountDetailPage() { @@ -18,8 +20,7 @@ export default function VendorAccountDetailPage() { apiDelete={api.destroyVendorAccount} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Member", value: {model.member.name}, @@ -70,7 +71,7 @@ export default function VendorAccountDetailPage() { }, ]} />, - model.contact && ( + model.contact ? ( + ) : ( + ( + + + Provision New + + + ), + }, + ]} + /> ), , [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Vendor", value: {model.vendor.name}, diff --git a/adminapp/src/pages/VendorDetailPage.jsx b/adminapp/src/pages/VendorDetailPage.jsx index bf562f49..20c53715 100644 --- a/adminapp/src/pages/VendorDetailPage.jsx +++ b/adminapp/src/pages/VendorDetailPage.jsx @@ -4,7 +4,7 @@ import BoolCheckmark from "../components/BoolCheckmark"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import formatDate from "../modules/formatDate"; import React from "react"; @@ -15,8 +15,7 @@ export default function VendorDetailPage() { apiGet={api.getVendor} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Slug", value: model.slug }, ...detailPageImageProperties(model.image), diff --git a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx index fb548828..b738fd83 100644 --- a/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceCategoryDetailPage.jsx @@ -2,6 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import React from "react"; export default function VendorServiceCategoryDetailPage() { @@ -11,7 +12,7 @@ export default function VendorServiceCategoryDetailPage() { apiGet={api.getVendorServiceCategory} canEdit properties={(model) => [ - { label: "ID", value: model.id }, + ...resourceDetailCommonFields(model), { label: "Name", value: model.name }, { label: "Slug", value: model.slug }, { diff --git a/adminapp/src/pages/VendorServiceDetailPage.jsx b/adminapp/src/pages/VendorServiceDetailPage.jsx index c39799e5..9133e86f 100644 --- a/adminapp/src/pages/VendorServiceDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceDetailPage.jsx @@ -5,6 +5,7 @@ import CategoriesRelatedList from "../components/CategoriesRelatedList"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; import detailPageImageProperties from "../components/detailPageImageProperties"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import { dayjs } from "../modules/dayConfig"; import React from "react"; @@ -15,8 +16,7 @@ export default function VendorServiceDetailPage() { apiGet={api.getVendorService} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), ...detailPageImageProperties(model.image), { label: "External Name", value: model.externalName }, { label: "Internal Name", value: model.internalName }, diff --git a/adminapp/src/pages/VendorServiceRateDetailPage.jsx b/adminapp/src/pages/VendorServiceRateDetailPage.jsx index c7d3df23..545c9700 100644 --- a/adminapp/src/pages/VendorServiceRateDetailPage.jsx +++ b/adminapp/src/pages/VendorServiceRateDetailPage.jsx @@ -2,7 +2,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import RelatedList from "../components/RelatedList"; import ResourceDetail from "../components/ResourceDetail"; -import { dayjs } from "../modules/dayConfig"; +import resourceDetailCommonFields from "../components/resourceDetailCommonFields"; import Money from "../shared/react/Money"; import React from "react"; @@ -13,8 +13,7 @@ export default function VendorServiceRateDetailPage() { apiGet={api.getVendorServiceRate} canEdit properties={(model) => [ - { label: "ID", value: model.id }, - { label: "Created At", value: dayjs(model.createdAt) }, + ...resourceDetailCommonFields(model), { label: "Internal Name", value: model.internalName }, { label: "External Name", value: model.externalName }, { label: "Surcharge", value: {model.surcharge} }, diff --git a/data/i18n/seeds/en/strings.json b/data/i18n/seeds/en/strings.json index c8c01820..ab1fdcab 100644 --- a/data/i18n/seeds/en/strings.json +++ b/data/i18n/seeds/en/strings.json @@ -352,6 +352,7 @@ "private_accounts.action_relink_app": "Reconnect app", "private_accounts.action_setup_payment": "Setup payment", "private_accounts.auth_error": "Sorry, something went wrong. You can try again, or email info@mysuma.org.", + "private_accounts.checklist_pay_balance": "Pay account balance", "private_accounts.checklist_setup_payment": "Connect a payment method", "private_accounts.checklist_review_terms": "Review terms and conditions", "private_accounts.checklist_link_app": "Link your app", @@ -364,6 +365,7 @@ "private_accounts.linkview_polling_detail": "Hold tight, this can take a minute.", "private_accounts.list_intro": "Link your suma account to 3rd party apps for for security and additional discounts.", "private_accounts.list_no_private_accounts": "It looks like no vendors are set up for Private Accounts. Contact your administrator for more information.", + "private_accounts.pay_balance_explanation": "**You currently owe {{amount, sumaCurrency}}.** To use this service, you must pay off this balance.", "private_accounts.view_header_steps": "Process", "private_accounts.view_header_terms": "Review Terms of Use", "private_accounts.view_header_link": "Link app", diff --git a/data/i18n/seeds/es/strings.json b/data/i18n/seeds/es/strings.json index 67d7c4fb..937ebb87 100644 --- a/data/i18n/seeds/es/strings.json +++ b/data/i18n/seeds/es/strings.json @@ -352,6 +352,7 @@ "private_accounts.action_relink_app": "Reconectar app", "private_accounts.action_setup_payment": "Configurar pago", "private_accounts.auth_error": "Perdón, algo salió mal. Puede intentarlo nuevamente o enviar un correo electrónico a info@mysuma.org.", + "private_accounts.checklist_pay_balance": "Pagar saldo de cuenta", "private_accounts.checklist_setup_payment": "Conectar un método de pago", "private_accounts.checklist_review_terms": "Revisar términos y condiciones", "private_accounts.checklist_link_app": "Vincula tu app", @@ -364,6 +365,7 @@ "private_accounts.linkview_polling_detail": "Espera un momento, esto puede tardar un minuto.", "private_accounts.list_intro": "Vincula tu cuenta de suma con apps de terceros para mayor seguridad y descuentos adicionales.", "private_accounts.list_no_private_accounts": "Parece que no hay proveedores configurados para Cuentas Privadas. Comunícate con tu administrador para más información.", + "private_accounts.pay_balance_explanation": "**Actualmente debes {{amount, sumaCurrency}}.** Para usar este servicio, debes pagar este saldo.", "private_accounts.view_header_steps": "Procesar", "private_accounts.view_header_terms": "Revisar Términos de Uso", "private_accounts.view_header_link": "Vincular app", diff --git a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb index da0eb892..58311dde 100644 --- a/lib/suma/admin_api/anon_proxy_vendor_accounts.rb +++ b/lib/suma/admin_api/anon_proxy_vendor_accounts.rb @@ -67,23 +67,27 @@ def lookup!(rw) end end - post :revoke_lime_login do - a = lookup!(:write) - a.member.audit_activity("revokelime", action: a) - Suma::Program::ServiceRevoker.new(dry_run: false).close_lime_account(a) - created_resource_headers(a.id, a.admin_link) - admin_action_handler :update - status 200 - present a, with: DetailedVendorAccountEntity - end + resource :revoke_lime_login do + post do + a = lookup!(:write) + a.member.audit_activity("revokelime", action: a) + Suma::Program::ServiceRevoker.new(dry_run: false).close_lime_account(a) + created_resource_headers(a.id, a.admin_link) + admin_action_handler :update + status 200 + present a, with: DetailedVendorAccountEntity + end - post "revoke_lime_login/finish" do - a = lookup!(:write) - a.update(pending_closure: false, contact: nil) - created_resource_headers(a.id, a.admin_link) - admin_action_handler :update - status 200 - present a, with: DetailedVendorAccountEntity + post :finish do + a = lookup!(:write) + adminerror!(409, "Magic link was never set on the account. Wait longer, or try revoking Lime again.") if + a.latest_access_code.blank? + a.update(pending_closure: false, contact: nil) + created_resource_headers(a.id, a.admin_link) + admin_action_handler :update + status 200 + present a, with: DetailedVendorAccountEntity + end end post :revoke_lyft_pass do diff --git a/lib/suma/anon_proxy/message_handler.rb b/lib/suma/anon_proxy/message_handler.rb index 75df5ff2..3c69c4ee 100644 --- a/lib/suma/anon_proxy/message_handler.rb +++ b/lib/suma/anon_proxy/message_handler.rb @@ -33,10 +33,21 @@ def can_handle?(parsed_message) = raise NotImplementedError # It may also just take some other action, like updating a database object. # If the operation noops, +Result#handled+ is set to +false+. # + # NOTE: This method may be called even if the member is not longer eligible + # for the the vendor account/configuration. Subclasses must be sure to check + # if this is the case, and take appropriate action. + # # @param vendor_account_message [Suma::AnonProxy::VendorAccountMessage] # @return [Result] def handle(vendor_account_message) = raise NotImplementedError + def member_can_access_vendor_services?(vendor_account_message) + va = vendor_account_message.vendor_account + return false unless va.configuration.eligible_to?(va.member, as_of: vendor_account_message.message_timestamp) + return false if Suma::Payment.service_usage_prohibited_reason(va.member.payment_account) + return true + end + # After the relay parses the message, # handle it according to who sent it. # @@ -62,21 +73,6 @@ def self.handle(relay, message) self.logger.warn("no_vendor_account_for_message", message:, relay: relay.key) return nil end - unless vendor_account.configuration.eligible_to?(vendor_account.member, as_of: message.timestamp) - # Do not handle messages we get where the user is not eligble. - # Users can often request auth messages themselves, like a forgot password/magic link - # using their anonymous contact info, in which case it'd come to us. - # We want to ignore it; not send it onto the user. - # NOTE: There is similar code in the lime handler; - # maybe that should be generalized too. - self.logger.warn("member_cannot_access_configuration", - message:, - relay: relay.key, - member: vendor_account.member.name, - vendor_account_id: vendor_account.id,) - return nil - end - vendor_account.db.transaction do vam = Suma::AnonProxy::VendorAccountMessage.new( message_id: message.message_id, diff --git a/lib/suma/anon_proxy/message_handler/lime.rb b/lib/suma/anon_proxy/message_handler/lime.rb index 176e83e6..1d8da041 100644 --- a/lib/suma/anon_proxy/message_handler/lime.rb +++ b/lib/suma/anon_proxy/message_handler/lime.rb @@ -48,7 +48,8 @@ def handle(vendor_account_message) vendor_account.save_changes result.handled = true return result - elsif Suma::Payment.service_usage_prohibited_reason(vendor_account.member.payment_account) + end + unless self.member_can_access_vendor_services?(vendor_account_message) # It is possible for a Lime user who is logged out to manually request a reset code link. # We normally can't tell apart requests that we make, from requests that they make; # and since there is usually no need to, we don't worry about it. diff --git a/lib/suma/anon_proxy/vendor_account.rb b/lib/suma/anon_proxy/vendor_account.rb index f6d6a0bd..a0cdd4c8 100644 --- a/lib/suma/anon_proxy/vendor_account.rb +++ b/lib/suma/anon_proxy/vendor_account.rb @@ -148,11 +148,14 @@ def ui_state_v1(now:) else :relink end + cash_ledger = self.member.payment_account&.cash_ledger! + balance_payoff_needed = has_payment_method && cash_ledger && Suma::Payment.chargeable_balance?(cash_ledger.balance) return UIStateV1.new( index_card_mode:, needs_linking:, requires_payment_method:, has_payment_method:, + balance_payoff_needed:, description_text: self.configuration.description_text, terms_text: self.configuration.terms_text, help_text: self.configuration.help_text, @@ -186,8 +189,14 @@ class UIStateV1 < Suma::TypedStruct # True if this configuration requires a payment method (see +requires_payment_instrument?+). # Used to control what the potential flow is. attr_reader :requires_payment_method + # True if the user has a default payment method. + # Only relevant if +requires_payment_method+. attr_reader :has_payment_method + # True if there is a default payment method, + # but the user's balance needs to be paid off to relink. + # Is always false if +has_payment_method+ is false. + attr_reader :balance_payoff_needed # Localized text for the card description. attr_reader :description_text @@ -198,27 +207,32 @@ class UIStateV1 < Suma::TypedStruct requires(all: true) - def prompt_for_payment_method = self.requires_payment_method && !self.has_payment_method + def show_payment_step = self.requires_payment_method + def term_step_index = self.show_payment_step ? 1 : 0 + def link_step_index = self.show_payment_step ? 2 : 1 + def step_index_progress_percentages = self.show_payment_step ? [20, 40, 60, 80] : [20, 60, 80] end def _admin_actions_self r = [] - if self.auth_to_vendor.class.key == :lime && self.latest_access_code_set_at - r << if self.pending_closure - self._admin_action( - "Finish Lime Revocation", - "/adminapi/v1/anon_proxy_vendor_accounts/#{self.id}/revoke_lime_login/finish", - confirmation_prompt: "Did you log in with the Lime magic link?", - ) - else - prompt = "This will start logging the user out of Lime. You need to refresh the page until " \ - "a Magic Link is present, then log in with it, then use Finish Lime Revocation." - self._admin_action( - "Revoke Lime Login", - "/adminapi/v1/anon_proxy_vendor_accounts/#{self.id}/revoke_lime_login", - confirmation_prompt: prompt, + if self.auth_to_vendor.class.key == :lime + prompt = "This will start logging the user out of Lime. You need to refresh the page until " \ + "a Magic Link is present, then log in with it, then use Finish Lime Revocation." + # We cannot be sure the Lime reset link came through, so make sure we can always re-request it. + revoke_action = self._admin_action( + "Revoke Lime Login", + "/adminapi/v1/anon_proxy_vendor_accounts/#{self.id}/revoke_lime_login", + confirmation_prompt: prompt, + ) + r << revoke_action + if self.pending_closure + finish_action = self._admin_action( + "Finish Lime Revocation", + "/adminapi/v1/anon_proxy_vendor_accounts/#{self.id}/revoke_lime_login/finish", + confirmation_prompt: "Did you log in with the Lime magic link?", ) - end + r << finish_action + end elsif self.auth_to_vendor.class.key == :lyft_pass && self.registrations.any? a = self._admin_action( "Revoke Lyft Pass", diff --git a/lib/suma/api/anon_proxy.rb b/lib/suma/api/anon_proxy.rb index 0378e674..eaab62ff 100644 --- a/lib/suma/api/anon_proxy.rb +++ b/lib/suma/api/anon_proxy.rb @@ -29,12 +29,8 @@ def lookup end end - params do - optional :terms_agreed, type: Boolean - end post :process do apva = lookup - apva.auth_to_vendor.auth(now: current_time) if params[:terms_agreed] status 200 present apva, with: AnonProxyVendorAccountEntity end @@ -153,7 +149,10 @@ class AnonProxyVendorAccountUIStateEntity < BaseEntity expose :needs_linking expose :requires_payment_method expose :has_payment_method - expose :prompt_for_payment_method + expose :balance_payoff_needed + expose :show_payment_step + expose :term_step_index + expose :link_step_index expose_translated :description_text expose_translated :terms_text expose_translated :help_text diff --git a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb index 293ad5a2..58a1b64b 100644 --- a/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb +++ b/spec/suma/admin_api/anon_proxy_vendor_accounts_spec.rb @@ -119,9 +119,10 @@ def make_item(_i) end describe "POST /v1/anon_proxy_vendor_accounts/:id/revoke_lime_login/finish" do + let(:vc) { Suma::Fixtures.anon_proxy_vendor_configuration.create(auth_to_vendor_key: "lime") } + let(:acct) { Suma::Fixtures.anon_proxy_vendor_account(configuration: vc).create(pending_closure: true) } + it "updates the account" do - vc = Suma::Fixtures.anon_proxy_vendor_configuration.create(auth_to_vendor_key: "lime") - acct = Suma::Fixtures.anon_proxy_vendor_account(configuration: vc).create(pending_closure: true) acct.replace_access_code("x", "https://link").save_changes post "/v1/anon_proxy_vendor_accounts/#{acct.id}/revoke_lime_login/finish" @@ -130,6 +131,12 @@ def make_item(_i) expect(last_response).to have_json_body.that_includes(id: acct.id) expect(acct.refresh).to have_attributes(contact: nil, pending_closure: false) end + + it "errors if the access code is not set" do + post "/v1/anon_proxy_vendor_accounts/#{acct.id}/revoke_lime_login/finish" + + expect(last_response).to have_status(409) + end end describe "POST /v1/anon_proxy_vendor_accounts/:id/revoke_lyft_pass", no_transaction_check: true do diff --git a/spec/suma/anon_proxy/message_handler_spec.rb b/spec/suma/anon_proxy/message_handler_spec.rb index de09c78a..0a9255aa 100644 --- a/spec/suma/anon_proxy/message_handler_spec.rb +++ b/spec/suma/anon_proxy/message_handler_spec.rb @@ -49,18 +49,6 @@ expect(logs).to include(include_json(message: eq("no_vendor_account_for_message"))) end - it "logs a warning and returns nil if the member cannot access the vendor configuration" do - stub_const("Suma::Eligibility::RESOURCES_DEFAULT_ACCESSIBLE", false) - vendor_account = Suma::Fixtures.anon_proxy_vendor_account.with_contact.create - fake_handler.class.can_handle_callback = proc { true } - msg = relay.parse_message({from: "fake-email-relay", timestamp: Time.now, to: vendor_account.contact.email}) - logs = capture_logs_from(described_class.logger, level: :warn, formatter: :json) do - expect(described_class.handle(relay, msg)).to be_nil - end - expect(fake_handler.class.handled).to be_empty - expect(logs).to include(include_json(message: eq("member_cannot_access_configuration"))) - end - describe "with a handleable message" do let(:vendor_account) { Suma::Fixtures.anon_proxy_vendor_account.with_contact.create } let(:message) do @@ -249,5 +237,18 @@ def create_message(file) expect(vendor_account.refresh).to have_attributes(latest_access_code: nil) end end + + describe "when the member is no longer eligible for the vendor configuration" do + it "logs a warning and returns nil if the member cannot access the vendor configuration" do + stub_const("Suma::Eligibility::RESOURCES_DEFAULT_ACCESSIBLE", false) + expect_sentry_capture(type: :message, arg_matcher: include("Prohibited Lime")) + got = Suma::AnonProxy::MessageHandler.handle( + Suma::AnonProxy::Relay.create!("fake-email-relay"), + signin_message, + ) + expect(got).to have_attributes(vendor_account:, outbound_delivery: nil) + expect(vendor_account.refresh).to have_attributes(latest_access_code: nil) + end + end end end diff --git a/spec/suma/anon_proxy/vendor_account_spec.rb b/spec/suma/anon_proxy/vendor_account_spec.rb index 1a175760..a3bde4a8 100644 --- a/spec/suma/anon_proxy/vendor_account_spec.rb +++ b/spec/suma/anon_proxy/vendor_account_spec.rb @@ -137,7 +137,7 @@ it "can revoke lime access" do vc = Suma::Fixtures.anon_proxy_vendor_configuration.create(auth_to_vendor_key: "lime") acct = Suma::Fixtures.anon_proxy_vendor_account(configuration: vc).create - expect(acct.admin_actions).to be_empty + expect(acct.admin_actions).to contain_exactly(have_attributes(label: "Revoke Lime Login")) acct.replace_access_code("x", "https://link") expect(acct.admin_actions).to contain_exactly(have_attributes(label: "Revoke Lime Login")) end @@ -146,7 +146,10 @@ vc = Suma::Fixtures.anon_proxy_vendor_configuration.create(auth_to_vendor_key: "lime") acct = Suma::Fixtures.anon_proxy_vendor_account(configuration: vc).create(pending_closure: true) acct.replace_access_code("x", "https://link") - expect(acct.admin_actions).to contain_exactly(have_attributes(label: "Finish Lime Revocation")) + expect(acct.admin_actions).to contain_exactly( + have_attributes(label: "Revoke Lime Login"), + have_attributes(label: "Finish Lime Revocation"), + ) end it "can revoke lyft access" do @@ -200,6 +203,12 @@ describe "ui_state_v1" do let!(:card) { Suma::Fixtures.card.member(member).create } + let!(:cash_ledger) { Suma::Payment.ensure_cash_ledger(va.member) } + + before(:each) do + Suma::Payment.minimum_cash_balance_grace_cents = -50 + Suma::Fixtures.book_transaction.from(cash_ledger).create(amount: money("$0.35")) + end it "represents the link state" do expect(va.ui_state_v1(now: as_of)).to have_attributes( @@ -207,6 +216,7 @@ needs_linking: true, requires_payment_method: true, has_payment_method: true, + balance_payoff_needed: false, ) end @@ -217,6 +227,7 @@ needs_linking: false, requires_payment_method: true, has_payment_method: true, + balance_payoff_needed: false, ) end @@ -228,8 +239,36 @@ needs_linking: false, requires_payment_method: true, has_payment_method: false, + balance_payoff_needed: false, ) end + + describe "with a chargeable balance", reset_configuration: Suma::Payment do + before(:each) do + Suma::Fixtures.book_transaction.from(cash_ledger).create(amount: money("$5")) + end + + it "represents the negative balance state" do + expect(va.ui_state_v1(now: as_of)).to have_attributes( + index_card_mode: :link, + needs_linking: true, + requires_payment_method: true, + has_payment_method: true, + balance_payoff_needed: true, + ) + end + + it "does not require a balance payoff if there is no instrument" do + card.soft_delete + expect(va.ui_state_v1(now: as_of)).to have_attributes( + index_card_mode: :link, + needs_linking: true, + requires_payment_method: true, + has_payment_method: false, + balance_payoff_needed: false, + ) + end + end end end end diff --git a/spec/suma/api/anon_proxy_spec.rb b/spec/suma/api/anon_proxy_spec.rb index 43d84868..c08bcf28 100644 --- a/spec/suma/api/anon_proxy_spec.rb +++ b/spec/suma/api/anon_proxy_spec.rb @@ -16,6 +16,10 @@ Suma::AnonProxy::AuthToVendor::Fake.reset end + after(:each) do + Suma::AnonProxy::AuthToVendor::Fake.reset + end + describe "GET /v1/anon_proxy/vendor_accounts" do it "returns vendor accounts" do va = Suma::Fixtures.anon_proxy_vendor_account(member:).create @@ -33,19 +37,19 @@ end describe "POST /v1/anon_proxy/vendor_accounts/:id/process" do - it "auths to vendor if terms_agreed" do + it "returns the vendor account" do va = Suma::Fixtures.anon_proxy_vendor_account(member:).create - post "/v1/anon_proxy/vendor_accounts/#{va.id}/process", terms_agreed: true + post "/v1/anon_proxy/vendor_accounts/#{va.id}/process" expect(last_response).to have_status(200) - expect(va.refresh).to have_attributes(contact: be_a(Suma::AnonProxy::MemberContact)) + expect(last_response).to have_json_body.that_includes(:ui_state_v1) end it "errors if the member cannot access the account" do va = Suma::Fixtures.anon_proxy_vendor_account.create - post "/v1/anon_proxy/vendor_accounts/#{va.id}/process", terms_agreed: true + post "/v1/anon_proxy/vendor_accounts/#{va.id}/process" expect(last_response).to have_status(403) end diff --git a/webapp/src/pages/PrivateAccountDetail.jsx b/webapp/src/pages/PrivateAccountDetail.jsx index c0770934..e8a3198a 100644 --- a/webapp/src/pages/PrivateAccountDetail.jsx +++ b/webapp/src/pages/PrivateAccountDetail.jsx @@ -6,12 +6,14 @@ import LayoutContainer from "../components/LayoutContainer"; import PageLoader from "../components/PageLoader"; import RLink from "../components/RLink.jsx"; import { dt, t } from "../localization"; +import { scaleMoney } from "../shared/money.js"; import useAsyncFetch from "../shared/react/useAsyncFetch"; import useUnmountEffect from "../shared/react/useUnmountEffect.jsx"; -import { useError } from "../state/useError.jsx"; +import { extractErrorCode, useError } from "../state/useError.jsx"; +import useScreenLoader from "../state/useScreenLoader.jsx"; +import useUser from "../state/useUser.jsx"; import { CanceledError } from "axios"; import clsx from "clsx"; -import get from "lodash/get"; import React from "react"; import { ProgressBar } from "react-bootstrap"; import Alert from "react-bootstrap/Alert"; @@ -61,7 +63,9 @@ export default function PrivateAccountDetail() { ); } - if (view === VIEW_TERMS) { + if (view === VIEW_BALANCE) { + return ; + } else if (view === VIEW_TERMS) { return ; } else if (view === VIEW_LINK) { return ; @@ -74,38 +78,55 @@ export default function PrivateAccountDetail() { * @param setView */ function StepsView({ account, setView }) { + const { + requiresPaymentMethod, + hasPaymentMethod, + balancePayoffNeeded, + termStepIndex, + linkStepIndex, + } = account.uiStateV1; + const primaryProps = { children: t("common.next"), variant: "primary" }; if (account.uiStateV1.requiresPaymentMethod && !account.uiStateV1.hasPaymentMethod) { primaryProps.as = RLink; primaryProps.to = `/add-card?returnToImmediate=/private-account/${account.id}`; } else { - primaryProps.onClick = () => setView(VIEW_TERMS); + const nextView = balancePayoffNeeded ? VIEW_BALANCE : VIEW_TERMS; + primaryProps.onClick = () => setView(nextView); + } + let potentialFirstStep; + if (requiresPaymentMethod) { + let checked, locKey; + if (balancePayoffNeeded) { + locKey = "private_accounts.checklist_pay_balance"; + checked = false; + } else if (!hasPaymentMethod) { + locKey = "private_accounts.checklist_setup_payment"; + checked = false; + } else { + locKey = "private_accounts.checklist_setup_payment"; + checked = true; + } + potentialFirstStep = ( +
  • + + {t(locKey)} +
  • + ); } - const secondStepNumber = account.uiStateV1.requiresPaymentMethod ? 2 : 1; return ( - +
      - {account.uiStateV1.requiresPaymentMethod && ( -
    • - - {t("private_accounts.checklist_setup_payment")} -
    • - )} + {potentialFirstStep}
    • - + {t("private_accounts.checklist_review_terms")}
    • - + {t("private_accounts.checklist_link_app")}
    @@ -114,13 +135,58 @@ function StepsView({ account, setView }) { ); } +/** + * @param {AnonProxyVendorAccount} account + * @param setView + */ +function BalanceView({ setView }) { + const { user, setUser } = useUser(); + const [error, setError] = useError(); + const screenLoader = useScreenLoader(); + + function handleClick(e) { + screenLoader.turnOn(); + setError(null); + e.preventDefault(); + api + .chargeLedgerBalance() + .then((r) => { + setUser(r.data); + setView(VIEW_TERMS); + }) + .catch((e) => setError(extractErrorCode(e))) + .finally(screenLoader.turnOff); + } + + const balance = scaleMoney(user.chargeableCashBalance, -1); + + return ( + +
    {t("private_accounts.pay_balance_explanation", { amount: balance })}
    + + setView(VIEW_STEPS), + }} + primaryProps={{ + children: t("payments.negative_balance_action", { amount: balance }), + variant: "danger", + onClick: handleClick, + }} + /> +
    + ); +} + /** * @param {AnonProxyVendorAccount} account * @param setView */ function TermsView({ account, setView }) { return ( - + {dt(account.uiStateV1.termsText)} { - console.error(get(e, "response.data") || e); + .catch(() => { setError({t("private_accounts.auth_error")}); setButtonStatus(LINKBTN_INITIAL); }); @@ -234,9 +299,8 @@ function LinkView({ account, setView }) { return ( {t("private_accounts.linkview_instructions")} @@ -267,15 +331,12 @@ const LINKBTN_POLLING = "link-polling"; const LINKBTN_SENT = "link-sent"; /** + * @param {AnonProxyVendorAccount} account * @param {string} header - * @param {string} view - * @param {number=} progress + * @param {number} progress * @param children */ -function ProgressContainer({ header, view, progress, children }) { - if (!progress) { - progress = { [VIEW_STEPS]: 20, [VIEW_TERMS]: 60, [VIEW_LINK]: 80 }[view]; - } +function ProgressContainer({ header, progress, children }) { return ( @@ -286,5 +347,6 @@ function ProgressContainer({ header, view, progress, children }) { } const VIEW_STEPS = "steps"; +const VIEW_BALANCE = "balance"; const VIEW_TERMS = "terms"; const VIEW_LINK = "link";