From 0f243d7bca6c00097033751db81ffff5a9e6fc74 Mon Sep 17 00:00:00 2001 From: Abdul <106555838+AbdulTheActivePiecer@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:56:17 +0300 Subject: [PATCH 1/3] fix(billing): resolve entitlement flags from the customer's plans (#14963) Co-authored-by: Claude Opus 5 (1M context) --- ...stomers-plans-never-from-customer-flags.md | 80 ++++++ .../ee-platform-plans-billing.md | 9 +- .../billing-providers/autumn-billing.ts | 8 +- .../billing-providers/autumn-utils.ts | 152 ++++++++--- .../app/ee/platform-plan/credits-gate.test.ts | 1 + .../autumn-entitlement-flags.test.ts | 255 ++++++++++++++++++ 6 files changed, 458 insertions(+), 47 deletions(-) create mode 100644 brain/knowledge/decisions/000030-entitlement-flags-are-resolved-from-the-customers-plans-never-from-customer-flags.md create mode 100644 packages/server/api/test/unit/ee/platform-plan/autumn-entitlement-flags.test.ts diff --git a/brain/knowledge/decisions/000030-entitlement-flags-are-resolved-from-the-customers-plans-never-from-customer-flags.md b/brain/knowledge/decisions/000030-entitlement-flags-are-resolved-from-the-customers-plans-never-from-customer-flags.md new file mode 100644 index 000000000000..b1042499bf16 --- /dev/null +++ b/brain/knowledge/decisions/000030-entitlement-flags-are-resolved-from-the-customers-plans-never-from-customer-flags.md @@ -0,0 +1,80 @@ +--- +status: proposed +--- + +# Entitlement flags are resolved from the customer's plans, never from `customer.flags` + +## Context + +`refreshEntitlements` projects the Autumn customer into `platform_plan`. The obvious source for the +boolean feature flags is `customer.flags` — Autumn hands it over already flattened, keyed by feature +id, on the same response the balances come from. + +That map cannot carry the meaning we need. It is keyed by feature id, so when two attached plans grant +the same feature Autumn collapses them into one entry and reports `planId: null` — the identical shape +it uses for a standalone customer-level grant. And two plans routinely *are* attached: `free` is +`auto_enable`, and attaching a **purchase**-shaped plan (`appsumo`, `free_legacy`) does not replace it +the way a subscription does. So for those customers the flag set is the union of `free` and their real +plan, with every shared flag reporting no source plan. + +`toAutumnEntitlements` had already grown a special case around this — `showPoweredBy` was read as +`!isNil(flag.planId)` — which inverted itself under the merge and handed every AppSumo and +Free-Legacy platform free white-labelling. + +## Decision + +Resolve the flags from the plans themselves. Build an **entitlement plan set** — all active +subscriptions (add-ons included) plus every purchase that has not expired — and union the boolean +items on those plans. + +`free`, `free_legacy` and `appsumo` are **baseline** plans. They supply the flags when nothing else is +attached, and all three are dropped from the union as soon as a non-baseline, non-add-on plan appears, +so an AppSumo platform that later buys `plus` gets `plus`'s flags with no baseline leftovers. Add-ons +are never dropped and never trigger the drop, so a credit top-up cannot strip a free platform's flags. +No customer can reach zero flags. + +`customer.flags` is not read for the projection at all, and `billingEnforced` comes from the same set +rather than a second source. + +Balances stay on `customer.balances`: that is the real balance, and the only place a one-off top-up +grant appears. + +`planId` and `scheduledUsersLimit` keep using base subscriptions only. An add-on is neither the +platform's plan name nor a seat schedule. + +## Why + +The plan set is the same thing `planId` already resolves to, so the projected flags and the projected +plan name can no longer disagree — which is what the `showPoweredBy` special case was failing to +paper over. It also removes `flag.planId` from the design entirely, and that field is unusable by +construction: it cannot distinguish "granted by two plans" from "granted outside any plan". + +Subtracting `free`'s flags from `customer.flags` instead was rejected: it needs a catalog read to know +what `free` grants anyway, and it leaves the merged-`planId` trap in place for the next reader. +Fixing the Autumn catalog instead — dropping `auto_enable` from `free`, or making `appsumo` a +subscription — was rejected because it mutates live billing data and the next purchase-shaped plan +reintroduces the union. + +## Consequences + +AppSumo and Free-Legacy platforms get `showPoweredBy` back, so the "Powered by Activepieces" badge +returns for them. Nothing needs a migration: `platform_plan` rows self-heal on the next +`refreshEntitlements`. + +A flag granted directly onto a customer outside any plan is now invisible. That is accepted — +entitlements come from plans. + +**Every `getCustomer` call that feeds an entitlement decision must pass +`expand: ['subscriptions.plan', 'purchases.plan']`.** Without it the plans come back with no `items`, +every flag resolves absent, and `billingEnforced` in particular fails *open* — credit gating silently +stops for every platform. Nothing in the type system catches this, because `plan` is optional on both +subscriptions and purchases. Two defences: `writeCustomerStateCaches` takes the resolved +`grantedFeatureIds` as an explicit parameter rather than deriving it privately, so a new call site has +to confront the requirement; and `toGrantedFeatureIds` logs a warning when a customer has attachments +but none of them carry an expanded plan. + +The projection becomes an exhaustive `Pick` object literal +instead of a hand-written id array, so adding a `FeatureFlagId` that has a `platform_plan` column +fails the build until it is mapped. `agentsEnabled` joins the projection under that rule while no +Autumn plan grants it yet, which switches agents off for every Cloud platform and every license-keyed +EE self-host until the feature is attached to plans. CE is unaffected — it skips entitlement sync. diff --git a/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md b/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md index 43c5264f2407..60f3fef7cce2 100644 --- a/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md +++ b/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md @@ -50,6 +50,13 @@ Billing and entitlements are powered by [Autumn](https://useautumn.com). Each pl - **Cancelling has two UI entry points and a third path that never reaches the cancel call at all.** The billing page's "Cancel subscription" link (`app/routes/platform/billing/index.tsx`) and the plan selector's Free-plan "Downgrade" button (`plan-selector.tsx`) both render the same `CancelSubscriptionDialog` (the churn survey, which carries `planSelectorUtils.dropToFreeWarning` in its warning alert) and both call `cancelWithSeatCheck` from `useCancelSubscriptionGuard`. Anything added to the cancel moment (copy, survey options, telemetry) belongs in the dialog or the hook, never in one call site, or the other entry point silently skips it. The third path is the seat floor: when active users exceed the Free plan's seats, `cancelWithSeatCheck` opens the deactivate-users dialog *instead of* cancelling, and a `QUOTA_EXCEEDED` from the server does the same thing after the fact, so the user can leave the flow having intended to cancel without a single request reaching `/v1/platform-billing/cancel` — and with the survey answers they just typed thrown away (decision 000023). - **Every console endpoint AP calls must live under `/v1`.** AP instances self-host and upgrade on their own schedule, so an AP-facing console route is a public contract and the version segment is the only place a breaking change can be absorbed without stranding older instances. All `/api/v1/billing/*` routes comply; three do not and should move when next touched: `/api/external/grant-chat-plan` (called from `autumn-utils.ts`), `/api/chat-analytics/external/sync` and `/api/chat-analytics/external/rollout-funnel` (called from `ee/chat/chat-analytics-sync.ts`). Console-web-only routes are not AP-facing and stay unversioned. - **Anything that must happen on every cancellation goes in the console's `/api/v1/billing/cancel` *controller*, not in `billingService.cancel`.** That service method early-returns when the customer's plan is nil or Free, before it touches Autumn, so a side effect placed inside it silently never runs for exactly the customers whose state is unusual. The cancellation-feedback insert sits in the controller for this reason, and is best-effort: it logs on failure and never fails the cancellation. +- **`customer.flags` cannot tell you which plan granted a flag.** The map is keyed by feature id, so two plans granting the same boolean feature collapse into one entry reporting `planId: null` — the identical shape Autumn uses for a standalone customer-level grant. Anything branching on `flag.planId` therefore flips the moment a second plan grants that feature, which is how `showPoweredBy` inverted itself. Entitlement flags are resolved from the customer's plan set instead (decision 000030). +- **The `auto_enable` `free` plan stays attached underneath a purchase-shaped plan.** Attaching a *subscription* plan replaces `free`; a one-off *purchase* plan (`appsumo`, `free_legacy`) does not — see the subscription-vs-purchase classification bullet above. So `free` stays active and both `customer.flags` and `customer.balances` become the union of the two plans: flags leak in, and numeric balances add up (free's 1 seat beside the purchase's 1 seat reads as `granted: 2`). Flags are resolved from the plan set to avoid this; **balances deliberately are not** (decision 000030), so seat and credit figures still union for exactly those platforms. +- **`addOn` sits in two different places on a subscription and a purchase.** `GetCustomerSubscription` carries a top-level, non-optional `addOn` (the field `toBaseSubscriptions` uses); `GetCustomerPurchase` has none and only exposes it on the *expanded* `plan`. Code unifying the two — like `toEntitlementPlan` — is pushed down to the nested `plan?.addOn ?? false`, which reads an add-on as a base plan whenever the expand is missing, and a base plan is what triggers the baseline drop. Prefer the top-level field wherever the attachment is known to be a subscription. +- **Any `getCustomer` feeding an entitlement decision must pass `expand: ['subscriptions.plan', 'purchases.plan']`.** Without it the plans arrive with no `items`, every flag resolves absent, and `billingEnforced` fails *open* — credit gating silently stops platform-wide. Nothing in the type system catches this, since `plan` is optional on subscriptions and purchases alike. Two defences: `writeCustomerStateCaches` takes `grantedFeatureIds` as an explicit parameter so a new call site has to confront the requirement, and `toGrantedFeatureIds` warns when a customer has attachments but no expanded plan. +- **Adding a `platform_plan` property now fails the build until you say where its value comes from.** `mapAutumnFeaturesToPlatformPlan` returns `PlatformPlanProjection` (`Required>>`), so every property must either be projected or be named in the `NotProjectedFromAutumn` opt-out (`licenseKey`, `licenseExpiresAt`, `projectsLimit`, `dedicatedWorkers`, `canary`, `customDomainsEnabled`, `workerGroupId`); a new `FeatureFlagId` that also has a column must additionally be mapped in `toPlatformPlanFlags`. The predecessor `PLATFORM_PLAN_FLAG_FEATURE_IDS` array checked validity but never completeness — which is how `agentsEnabled` went unprojected and sat frozen at the migration default `true` while still gating its module and the UI. A *required* (non-`Nullable`) new property also breaks `OPEN_SOURCE_PLAN` and `AUTUMN_FREE_PLAN` in `core/shared/src/lib/ee/billing/index.ts`, which are full `PlatformPlanLimits` literals — loud, but the error says nothing about Autumn sync. +- **The `Required<>` in both guards is load-bearing.** `Nullable()` is `z.optional(z.nullable(...))`, so a column declared that way arrives as an *optional* key in the `Pick` and would slip past an unwrapped `Pick` unnoticed. +- **Those build errors only appear once `@activepieces/shared` has been rebuilt.** `tsc -p packages/server/api/tsconfig.app.json` has `paths: {}` and resolves the package to `packages/core/shared/dist/src/index.d.ts`, not its source, so editing the schema and typechecking the API without a shared build proves nothing. vitest is the opposite — it aliases `@activepieces/shared` to `src`, so tests see schema edits immediately. Build shared first, and remember `tsc` does not clean `dist`, so a deleted module lingers there until you remove it. ### Key files Entry point: `platformPlanService` (`platform-plan.service.ts`) for projection, usage, and seat checks; `billingProvider.get(log)` for everything billing. @@ -63,4 +70,4 @@ Entry point: `platformPlanService` (`platform-plan.service.ts`) for projection, - `packages/core/shared/src/lib/ee/billing/index.ts` — plan constants (`AUTUMN_FREE_PLAN`, `OPEN_SOURCE_PLAN`), checkout/top-up schemas - `packages/web/src/features/billing/` + `packages/web/src/app/routes/platform/billing/index.tsx` — plans, credits, seats, license activation UI -Decisions: `brain/decisions/000013-active-user-seat-floor-is-enforced-db-authoritatively.md`, `000014-pending-invitations-reserve-seats.md`, `000015-jit-provisioning-plans-imply-unlimited-seats.md`, `000016-managed-ai-metering-moves-to-centralized-worker-execution.md`, `000017-scheduled-downgrades-cap-seats-immediately.md`, `000018-usage-counts-report-to-posthog-only.md`, `000019-autumn-platform-plan-schema-ships-additively.md`, `000020-credit-gating-fails-open-on-an-unknown-balance.md`, `000021-legacy-free-platforms-are-comped-an-appsumo-clone-from-ensureenrolled.md`, `000022-non-self-serve-plans-are-deliberately-non-recurring.md`, `000023-cancellation-feedback-rides-the-cancel-call.md`. Paths verified 2026-07-26. +Decisions: `brain/decisions/000013-active-user-seat-floor-is-enforced-db-authoritatively.md`, `000014-pending-invitations-reserve-seats.md`, `000015-jit-provisioning-plans-imply-unlimited-seats.md`, `000016-managed-ai-metering-moves-to-centralized-worker-execution.md`, `000017-scheduled-downgrades-cap-seats-immediately.md`, `000018-usage-counts-report-to-posthog-only.md`, `000019-autumn-platform-plan-schema-ships-additively.md`, `000020-credit-gating-fails-open-on-an-unknown-balance.md`, `000021-legacy-free-platforms-are-comped-an-appsumo-clone-from-ensureenrolled.md`, `000022-non-self-serve-plans-are-deliberately-non-recurring.md`, `000023-cancellation-feedback-rides-the-cancel-call.md`, `000030-entitlement-flags-are-resolved-from-the-customers-plans-never-from-customer-flags.md` (proposed). Paths verified 2026-07-26. diff --git a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts index e9fce5f716d3..39e54d9fcf62 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts @@ -449,8 +449,12 @@ async function fetchCredits(log: FastifyBaseLogger, platformId: string): Promise if (isNil(client)) { return null } - const customer = await client.getCustomer() - return autumnUtils.writeCustomerStateCaches(platformId, customer) + const customer = await client.getCustomer({ expand: ['subscriptions.plan', 'purchases.plan'] }) + return autumnUtils.writeCustomerStateCaches({ + platformId, + customer, + grantedFeatureIds: autumnUtils.toGrantedFeatureIds(customer), + }) } async function fetchBillingOverview(log: FastifyBaseLogger, platformId: string): Promise { diff --git a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts index 2cd95149dbb2..d9717d008f75 100644 --- a/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts +++ b/packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts @@ -35,28 +35,7 @@ const FREE_LEGACY_COMP_ATTEMPT_TTL_SECONDS = 5 * 60 const PROJECT_ID_PROPERTY = 'projectId' const CREDIT_USAGE_MAX_GROUPS = 250 const AI_CREDIT_USAGE_SOURCES = [CreditUsageSource.AI, CreditUsageSource.CHAT] -const PLATFORM_PLAN_FLAG_FEATURE_IDS = [ - 'tablesEnabled', - 'eventStreamingEnabled', - 'environmentsEnabled', - 'analyticsEnabled', - 'showPoweredBy', - 'auditLogEnabled', - 'embeddingEnabled', - 'aiProvidersEnabled', - 'chatEnabled', - 'workerGroupsEnabled', - 'managePiecesEnabled', - 'manageTemplatesEnabled', - 'customAppearanceEnabled', - 'projectRolesEnabled', - 'globalConnectionsEnabled', - 'customRolesEnabled', - 'apiKeysEnabled', - 'ssoEnabled', - 'secretManagersEnabled', - 'scimEnabled', -] as const satisfies readonly (keyof PlatformPlanLimits & `${FeatureFlagId}`)[] +const BASELINE_PLAN_IDS: readonly string[] = [PlanName.FREE, PlanName.FREE_LEGACY, PlanName.APPSUMO] export const autumnUtils = { client({ secretKey, customerId }: AutumnClientParams) { @@ -181,10 +160,10 @@ export const autumnUtils = { if (isNil(client)) { return } - const customer = await client.getCustomer({ expand: ['subscriptions.plan'] }) + const customer = await client.getCustomer({ expand: ['subscriptions.plan', 'purchases.plan'] }) const entitlements = toAutumnEntitlements(customer) await platformPlanService(log).update({ platformId, ...autumnUtils.mapAutumnFeaturesToPlatformPlan(entitlements) }) - await autumnUtils.writeCustomerStateCaches(platformId, customer) + await autumnUtils.writeCustomerStateCaches({ platformId, customer, grantedFeatureIds: entitlements.grantedFeatureIds }) await autumnUtils.invalidateBillingOverview(platformId) await autumnUtils.provisionLicenseKeyIfPaid(log, platformId, entitlements.planId) }, @@ -212,22 +191,18 @@ export const autumnUtils = { async invalidateBillingOverview(platformId: string): Promise { await distributedStore.delete(getBillingOverviewKey(platformId)) }, - mapAutumnFeaturesToPlatformPlan(entitlements: AutumnEntitlements): Partial { - const flags: Partial = {} - for (const feature of PLATFORM_PLAN_FLAG_FEATURE_IDS) { - flags[feature] = entitlements.flags[feature] ?? false - } + mapAutumnFeaturesToPlatformPlan(entitlements: AutumnEntitlements): PlatformPlanProjection { const teamProjects = entitlements.balances[UnconsumableFeatureId.TEAM_PROJECTS_LIMIT] const users = entitlements.balances[UnconsumableFeatureId.USERS_LIMIT] const activeFlows = entitlements.balances[UnconsumableFeatureId.ACTIVE_FLOWS_LIMIT] const credits = entitlements.balances[ConsumableFeatureId.AP_CREDITS] return { - ...flags, + ...toPlatformPlanFlags(entitlements.grantedFeatureIds), plan: entitlements.planId, - billedTeamProjectsLimit: toProjectedLimit(teamProjects, 1), - usersLimit: toProjectedLimit(users, null), + billedTeamProjectsLimit: toPlatformPlanLimit(teamProjects, 1), + usersLimit: toPlatformPlanLimit(users, null), scheduledUsersLimit: entitlements.scheduledUsersLimit, - activeFlowsLimit: toProjectedLimit(activeFlows, null), + activeFlowsLimit: toPlatformPlanLimit(activeFlows, null), includedCredits: credits?.granted ?? 0, } }, @@ -237,14 +212,21 @@ export const autumnUtils = { async writeBalance({ platformId, featureId, balance }: WriteBalanceParams): Promise { await distributedStore.put(balanceCacheKey({ platformId, featureId }), autumnUtils.toBalanceCache(balance), CREDITS_CACHE_TTL_SECONDS) }, - billingEnforcedFromCustomer(customer: GetCustomerResponse): boolean { - return !isNil(customer.flags[FeatureFlagId.BILLING_ENFORCED]) + toGrantedFeatureIds(attachments: AutumnPlanAttachments): ReadonlySet { + const plans = toEntitlementPlans(attachments) + if (plans.length > 0 && plans.every((plan) => !plan.expanded)) { + system.globalLogger().warn('Autumn customer was read without expanded plans, so no entitlement resolves') + } + return new Set(plans.flatMap((plan) => plan.featureIds)) + }, + billingEnforcedFromGrantedFeatureIds(grantedFeatureIds: ReadonlySet): boolean { + return grantedFeatureIds.has(FeatureFlagId.BILLING_ENFORCED) }, - async writeCustomerStateCaches(platformId: string, customer: GetCustomerResponse): Promise { + async writeCustomerStateCaches({ platformId, customer, grantedFeatureIds }: WriteCustomerStateCachesParams): Promise { const creditsBalance = customer.balances[ConsumableFeatureId.AP_CREDITS] const appSumoBalance = customer.balances[ConsumableFeatureId.APP_SUMO_AI_CREDITS] await Promise.all([ - distributedStore.put(getBillingEnforcedKey(platformId), autumnUtils.billingEnforcedFromCustomer(customer), BILLING_ENFORCED_TTL_SECONDS), + distributedStore.put(getBillingEnforcedKey(platformId), autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds), BILLING_ENFORCED_TTL_SECONDS), isNil(creditsBalance) ? Promise.resolve() : autumnUtils.writeBalance({ platformId, featureId: ConsumableFeatureId.AP_CREDITS, balance: creditsBalance }), isNil(appSumoBalance) ? Promise.resolve() : autumnUtils.writeBalance({ platformId, featureId: ConsumableFeatureId.APP_SUMO_AI_CREDITS, balance: appSumoBalance }), ]) @@ -459,10 +441,6 @@ function toCreditUsage({ total, aiResults }: { total: AggregateEventsResponse, a } function toAutumnEntitlements(customer: GetCustomerResponse): AutumnEntitlements { - const flags: Record = {} - for (const [featureId, flag] of Object.entries(customer.flags)) { - flags[featureId] = featureId === FeatureFlagId.SHOW_POWERED_BY ? !isNil(flag.planId) : true - } const balances: Record = {} for (const [featureId, balance] of Object.entries(customer.balances)) { balances[featureId] = { @@ -486,12 +464,57 @@ function toAutumnEntitlements(customer: GetCustomerResponse): AutumnEntitlements : purchasedPlanId ?? baseSubscriptionPlanId return { planId, - flags, + grantedFeatureIds: autumnUtils.toGrantedFeatureIds(customer), balances, scheduledUsersLimit: toScheduledUsersLimit(baseSubscriptions), } } +function toEntitlementPlans(attachments: AutumnPlanAttachments): EntitlementPlan[] { + const now = Date.now() + const attached = [ + ...attachments.subscriptions.filter((subscription) => subscription.status === 'active'), + ...attachments.purchases.filter((purchase) => isNil(purchase.expiresAt) || purchase.expiresAt > now), + ].map(toEntitlementPlan) + const hasNonBaselinePlan = attached.some((plan) => !plan.addOn && !BASELINE_PLAN_IDS.includes(plan.planId)) + return attached.filter((plan) => !hasNonBaselinePlan || !BASELINE_PLAN_IDS.includes(plan.planId)) +} + +function toEntitlementPlan(attachment: AutumnPlanAttachment): EntitlementPlan { + return { + planId: attachment.planId, + addOn: attachment.plan?.addOn ?? false, + expanded: !isNil(attachment.plan), + featureIds: (attachment.plan?.items ?? []).map((item) => item.featureId), + } +} + +function toPlatformPlanFlags(grantedFeatureIds: ReadonlySet): PlatformPlanFlags { + return { + tablesEnabled: grantedFeatureIds.has(FeatureFlagId.TABLES_ENABLED), + eventStreamingEnabled: grantedFeatureIds.has(FeatureFlagId.EVENT_STREAMING_ENABLED), + environmentsEnabled: grantedFeatureIds.has(FeatureFlagId.ENVIRONMENTS_ENABLED), + analyticsEnabled: grantedFeatureIds.has(FeatureFlagId.ANALYTICS_ENABLED), + showPoweredBy: grantedFeatureIds.has(FeatureFlagId.SHOW_POWERED_BY), + auditLogEnabled: grantedFeatureIds.has(FeatureFlagId.AUDIT_LOG_ENABLED), + embeddingEnabled: grantedFeatureIds.has(FeatureFlagId.EMBEDDING_ENABLED), + aiProvidersEnabled: grantedFeatureIds.has(FeatureFlagId.AI_PROVIDERS_ENABLED), + chatEnabled: grantedFeatureIds.has(FeatureFlagId.CHAT_ENABLED), + agentsEnabled: grantedFeatureIds.has(FeatureFlagId.AGENTS_ENABLED), + workerGroupsEnabled: grantedFeatureIds.has(FeatureFlagId.WORKER_GROUPS_ENABLED), + managePiecesEnabled: grantedFeatureIds.has(FeatureFlagId.MANAGE_PIECES_ENABLED), + manageTemplatesEnabled: grantedFeatureIds.has(FeatureFlagId.MANAGE_TEMPLATES_ENABLED), + customAppearanceEnabled: grantedFeatureIds.has(FeatureFlagId.CUSTOM_APPEARANCE_ENABLED), + projectRolesEnabled: grantedFeatureIds.has(FeatureFlagId.PROJECT_ROLES_ENABLED), + globalConnectionsEnabled: grantedFeatureIds.has(FeatureFlagId.GLOBAL_CONNECTIONS_ENABLED), + customRolesEnabled: grantedFeatureIds.has(FeatureFlagId.CUSTOM_ROLES_ENABLED), + apiKeysEnabled: grantedFeatureIds.has(FeatureFlagId.API_KEYS_ENABLED), + ssoEnabled: grantedFeatureIds.has(FeatureFlagId.SSO_ENABLED), + secretManagersEnabled: grantedFeatureIds.has(FeatureFlagId.SECRET_MANAGERS_ENABLED), + scimEnabled: grantedFeatureIds.has(FeatureFlagId.SCIM_ENABLED), + } +} + function toScheduledUsersLimit(baseSubscriptions: GetCustomerResponse['subscriptions']): number | null { const scheduledSubscription = baseSubscriptions.find((subscription) => subscription.status === 'scheduled') const usersLimitItem = (scheduledSubscription?.plan?.items ?? []) @@ -502,7 +525,7 @@ function toScheduledUsersLimit(baseSubscriptions: GetCustomerResponse['subscript return usersLimitItem.included ?? null } -function toProjectedLimit(balance: AutumnFeatureBalance | undefined, whenAbsent: number | null): number | null { +function toPlatformPlanLimit(balance: AutumnFeatureBalance | undefined, whenAbsent: number | null): number | null { if (isNil(balance)) { return whenAbsent } @@ -533,11 +556,52 @@ type AutumnFeatureBalance = { type AutumnEntitlements = { planId: string | null - flags: Record + grantedFeatureIds: ReadonlySet balances: Record scheduledUsersLimit: number | null } +type AutumnPlanAttachment = { + planId: string + plan?: { + addOn: boolean + items: { featureId: string }[] + } +} + +type AutumnPlanAttachments = { + subscriptions: (AutumnPlanAttachment & { status: string })[] + purchases: (AutumnPlanAttachment & { expiresAt: number | null })[] +} + +type EntitlementPlan = { + planId: string + addOn: boolean + expanded: boolean + featureIds: string[] +} + +type WriteCustomerStateCachesParams = { + platformId: string + customer: GetCustomerResponse + grantedFeatureIds: ReadonlySet +} + +type PlatformPlanFlagId = Extract<`${FeatureFlagId}`, keyof PlatformPlanLimits> + +type PlatformPlanFlags = Required> + +type NotProjectedFromAutumn = + | 'licenseKey' + | 'licenseExpiresAt' + | 'projectsLimit' + | 'dedicatedWorkers' + | 'canary' + | 'customDomainsEnabled' + | 'workerGroupId' + +type PlatformPlanProjection = Required>> + type AutumnEnrollmentCredentials = { autumnCustomerId: string autumnApiKey: string diff --git a/packages/server/api/test/unit/app/ee/platform-plan/credits-gate.test.ts b/packages/server/api/test/unit/app/ee/platform-plan/credits-gate.test.ts index 646c729df9c9..0e94de805fcb 100644 --- a/packages/server/api/test/unit/app/ee/platform-plan/credits-gate.test.ts +++ b/packages/server/api/test/unit/app/ee/platform-plan/credits-gate.test.ts @@ -15,6 +15,7 @@ vi.mock('../../../../../src/app/ee/platform/platform-plan/billing-providers/autu readBalance: async ({ featureId }: { featureId: string }) => featureId === 'apCredits' ? storedCredits : null, resolveClientForPlatform: (...args: unknown[]) => mockResolveClientForPlatform(...args), + toGrantedFeatureIds: () => new Set(), writeCustomerStateCaches: async () => { storedCredits = autumnCredits return { credits: autumnCredits, appSumo: null } diff --git a/packages/server/api/test/unit/ee/platform-plan/autumn-entitlement-flags.test.ts b/packages/server/api/test/unit/ee/platform-plan/autumn-entitlement-flags.test.ts new file mode 100644 index 000000000000..926f287e6464 --- /dev/null +++ b/packages/server/api/test/unit/ee/platform-plan/autumn-entitlement-flags.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { autumnUtils } from '../../../../src/app/ee/platform/platform-plan/billing-providers/autumn-utils' + +const FREE_FLAGS = ['analyticsEnabled', 'apiKeysEnabled', 'billingEnforced', 'showPoweredBy', 'tablesEnabled'] +const APPSUMO_FLAGS = [...FREE_FLAGS, 'aiProvidersEnabled'] +const FREE_LEGACY_FLAGS = APPSUMO_FLAGS +const PLUS_FLAGS = APPSUMO_FLAGS +const LICENSE_KEY_ENTERPRISE_FLAGS = [ + 'aiProvidersEnabled', 'analyticsEnabled', 'apiKeysEnabled', 'auditLogEnabled', + 'customAppearanceEnabled', 'customRolesEnabled', 'environmentsEnabled', 'eventStreamingEnabled', + 'globalConnectionsEnabled', 'managePiecesEnabled', 'manageTemplatesEnabled', 'projectRolesEnabled', + 'scimEnabled', 'secretManagersEnabled', 'ssoEnabled', 'tablesEnabled', +] + +function subscription({ planId, featureIds, status = 'active', addOn = false }: SubscriptionParams): Subscription { + return { planId, status, plan: { addOn, items: featureIds.map((featureId) => ({ featureId })) } } +} + +function purchase({ planId, featureIds, expiresAt = null, addOn = false }: PurchaseParams): Purchase { + return { planId, expiresAt, plan: { addOn, items: featureIds.map((featureId) => ({ featureId })) } } +} + +function grantedFlags(attachments: { subscriptions: Subscription[], purchases: Purchase[] }): string[] { + return [...autumnUtils.toGrantedFeatureIds(attachments)].sort() +} + +describe('toGrantedFeatureIds', () => { + it('uses the free plan when it is the only attachment', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [], + }) + expect(granted).toEqual([...FREE_FLAGS].sort()) + }) + + it('keeps free alongside an appsumo purchase, since both are baseline', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS })], + }) + expect(granted).toEqual([...APPSUMO_FLAGS].sort()) + }) + + it('keeps free alongside a free_legacy purchase, since both are baseline', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'free_legacy', featureIds: FREE_LEGACY_FLAGS })], + }) + expect(granted).toEqual([...FREE_LEGACY_FLAGS].sort()) + }) + + it('uses the paid subscription that replaced free', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'plus', featureIds: PLUS_FLAGS })], + purchases: [], + }) + expect(granted).toEqual([...PLUS_FLAGS].sort()) + }) + + it('grants an enterprise plan neither showPoweredBy nor billingEnforced', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'old_enterprise', featureIds: LICENSE_KEY_ENTERPRISE_FLAGS })], + purchases: [], + }) + expect(granted).toEqual([...LICENSE_KEY_ENTERPRISE_FLAGS].sort()) + expect(granted).not.toContain('showPoweredBy') + expect(granted).not.toContain('billingEnforced') + }) + + it('drops both a baseline subscription and a baseline purchase once a real plan is attached', () => { + const granted = grantedFlags({ + subscriptions: [ + subscription({ planId: 'free', featureIds: FREE_FLAGS }), + subscription({ planId: 'old_enterprise', featureIds: LICENSE_KEY_ENTERPRISE_FLAGS }), + ], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS })], + }) + expect(granted).toEqual([...LICENSE_KEY_ENTERPRISE_FLAGS].sort()) + expect(granted).not.toContain('showPoweredBy') + expect(granted).not.toContain('billingEnforced') + }) + + it('does not let an add-on strip a free platform of its flags', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'credit_topup', featureIds: [], addOn: true })], + }) + expect(granted).toEqual([...FREE_FLAGS].sort()) + }) + + it('includes flags an add-on grants without dropping the baseline plan', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'future_addon', featureIds: ['chatEnabled'], addOn: true })], + }) + expect(granted).toEqual([...FREE_FLAGS, 'chatEnabled'].sort()) + }) + + it('ignores an expired purchase', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS, expiresAt: Date.now() - 1000 })], + }) + expect(granted).toEqual([...FREE_FLAGS].sort()) + }) + + it('honours a purchase that has not expired yet', () => { + const granted = grantedFlags({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS, expiresAt: Date.now() + 60_000 })], + }) + expect(granted).toEqual([...APPSUMO_FLAGS].sort()) + }) + + it('ignores a scheduled subscription', () => { + const granted = grantedFlags({ + subscriptions: [ + subscription({ planId: 'plus', featureIds: PLUS_FLAGS }), + subscription({ planId: 'old_enterprise', featureIds: LICENSE_KEY_ENTERPRISE_FLAGS, status: 'scheduled' }), + ], + purchases: [], + }) + expect(granted).toEqual([...PLUS_FLAGS].sort()) + }) + + it('grants nothing when the customer has no attachment at all', () => { + expect(grantedFlags({ subscriptions: [], purchases: [] })).toEqual([]) + }) +}) + +describe('billingEnforcedFromGrantedFeatureIds', () => { + it('enforces billing for a free platform', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [], + }) + expect(autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds)).toBe(true) + }) + + it('does not leak enforcement from free onto a purchase-shaped enterprise plan', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'enterprise_lifetime', featureIds: LICENSE_KEY_ENTERPRISE_FLAGS })], + }) + expect(autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds)).toBe(false) + }) + + it('does not leak enforcement from free onto a subscription-shaped enterprise plan', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [ + subscription({ planId: 'free', featureIds: FREE_FLAGS }), + subscription({ planId: 'old_enterprise', featureIds: LICENSE_KEY_ENTERPRISE_FLAGS }), + ], + purchases: [], + }) + expect(autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds)).toBe(false) + }) + + it('keeps enforcement for an appsumo platform, whose own plan grants it', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS })], + }) + expect(autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds)).toBe(true) + }) + + it('does not let a credit top-up add-on drop enforcement', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'credit_topup', featureIds: [], addOn: true })], + }) + expect(autumnUtils.billingEnforcedFromGrantedFeatureIds(grantedFeatureIds)).toBe(true) + }) +}) + +describe('mapAutumnFeaturesToPlatformPlan', () => { + it('projects every platform plan flag column for an appsumo customer', () => { + const grantedFeatureIds = autumnUtils.toGrantedFeatureIds({ + subscriptions: [subscription({ planId: 'free', featureIds: FREE_FLAGS })], + purchases: [purchase({ planId: 'appsumo', featureIds: APPSUMO_FLAGS })], + }) + const projection = autumnUtils.mapAutumnFeaturesToPlatformPlan({ + planId: 'appsumo', + grantedFeatureIds, + balances: {}, + scheduledUsersLimit: null, + }) + expect(projection).toMatchObject({ + plan: 'appsumo', + tablesEnabled: true, + analyticsEnabled: true, + apiKeysEnabled: true, + aiProvidersEnabled: true, + showPoweredBy: true, + agentsEnabled: false, + eventStreamingEnabled: false, + environmentsEnabled: false, + auditLogEnabled: false, + embeddingEnabled: false, + chatEnabled: false, + workerGroupsEnabled: false, + managePiecesEnabled: false, + manageTemplatesEnabled: false, + customAppearanceEnabled: false, + projectRolesEnabled: false, + globalConnectionsEnabled: false, + customRolesEnabled: false, + ssoEnabled: false, + secretManagersEnabled: false, + scimEnabled: false, + }) + }) + + it('never projects billingEnforced, which has no platform plan column', () => { + const projection = autumnUtils.mapAutumnFeaturesToPlatformPlan({ + planId: 'free', + grantedFeatureIds: new Set(FREE_FLAGS), + balances: {}, + scheduledUsersLimit: null, + }) + expect(projection).not.toHaveProperty('billingEnforced') + expect(projection.showPoweredBy).toBe(true) + }) +}) + +type PlanItems = { + addOn: boolean + items: { featureId: string }[] +} + +type Subscription = { + planId: string + status: string + plan: PlanItems +} + +type Purchase = { + planId: string + expiresAt: number | null + plan: PlanItems +} + +type SubscriptionParams = { + planId: string + featureIds: string[] + status?: string + addOn?: boolean +} + +type PurchaseParams = { + planId: string + featureIds: string[] + expiresAt?: number | null + addOn?: boolean +} From 905da8f1b555beed066032890bfa96b90dd8b34f Mon Sep 17 00:00:00 2001 From: Bartosz Majewski <30874844+majewskibartosz@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:27:19 +0200 Subject: [PATCH 2/3] fix(pieces): installing a new npm piece no longer fails on first install (#14333) Co-authored-by: AbdulTheActivePiecer --- .../server/api/src/app/pieces/piece-bundle.ts | 14 ++++++-- .../ce/pieces/piece-bundle.test.ts | 33 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/server/api/src/app/pieces/piece-bundle.ts b/packages/server/api/src/app/pieces/piece-bundle.ts index 6bed1b876066..3ed84c6c29d8 100644 --- a/packages/server/api/src/app/pieces/piece-bundle.ts +++ b/packages/server/api/src/app/pieces/piece-bundle.ts @@ -2,10 +2,11 @@ import { isNil, tryCatch } from '@activepieces/core-utils' import { safeHttp } from '@activepieces/server-utils' import { FileType, PackageType, PieceType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { IsNull } from 'typeorm' import { fileRepo } from '../file/file.service' import { system } from '../helper/system/system' import { AppSystemProp } from '../helper/system/system-props' -import { pieceMetadataService } from './metadata/piece-metadata-service' +import { pieceMetadataService, pieceRepos } from './metadata/piece-metadata-service' // Resolves a piece to a single downloadable link (see ADR 0002 — "Pieces are distributed as links"). // Official/registry pieces resolve to the CDN tarball when available, else to the npm tarball. Custom @@ -25,7 +26,16 @@ export const pieceBundle = (log: FastifyBaseLogger) => ({ } const metadata = await pieceMetadataService(log).get({ name, version, platformId, projectId }) if (isNil(metadata)) { - return { type: 'not-found' } + const knownToPlatform = await pieceRepos().exists({ + where: [ + { name, version, platformId }, + { name, version, platformId: IsNull() }, + ], + }) + if (knownToPlatform) { + return { type: 'not-found' } + } + return { type: 'redirect', url: npmTarballUrl({ name, version }) } } if (metadata.packageType === PackageType.ARCHIVE && !isNil(metadata.archiveId)) { return { type: 'stream', archiveId: metadata.archiveId } diff --git a/packages/server/api/test/integration/ce/pieces/piece-bundle.test.ts b/packages/server/api/test/integration/ce/pieces/piece-bundle.test.ts index 7479b008968c..b09bdf4d8eb9 100644 --- a/packages/server/api/test/integration/ce/pieces/piece-bundle.test.ts +++ b/packages/server/api/test/integration/ce/pieces/piece-bundle.test.ts @@ -59,7 +59,34 @@ describe('Piece Bundle Endpoint', () => { expect(response.headers.location).toContain('piece-bundle-official-1.2.3.tgz') }) - it('scopes custom pieces by the token platform: owner can fetch, other platform gets 404', async () => { + it('redirects a first-time registry piece with no metadata row to the npm tarball', async () => { + const { mockPlatform, mockProject } = await mockAndSaveBasicSetup() + const token = await engineToken(mockProject.id, mockPlatform.id) + + const response = await app!.inject(bundleRequest('@alistairg/piece-hevy', '0.1.4', token)) + + expect(response.statusCode).toBe(StatusCodes.TEMPORARY_REDIRECT) + expect(response.headers.location).toBe('https://registry.npmjs.org/@alistairg/piece-hevy/-/piece-hevy-0.1.4.tgz') + }) + + it('keeps 404 for a piece the platform knows but cannot resolve (release-window gated)', async () => { + const { mockPlatform, mockProject } = await mockAndSaveBasicSetup() + await db.save('piece_metadata', createMockPieceMetadata({ + name: '@activepieces/piece-bundle-gated', + version: '1.0.0', + packageType: PackageType.REGISTRY, + pieceType: PieceType.OFFICIAL, + platformId: undefined, + minimumSupportedRelease: '900.0.0', + })) + const token = await engineToken(mockProject.id, mockPlatform.id) + + const response = await app!.inject(bundleRequest('@activepieces/piece-bundle-gated', '1.0.0', token)) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('scopes custom pieces by the token platform: owner streams the archive, other platform is redirected to public npm without the bytes', async () => { const platformA = await mockAndSaveBasicSetup() const platformB = await mockAndSaveBasicSetup() @@ -90,7 +117,9 @@ describe('Piece Bundle Endpoint', () => { expect(ownerResponse.rawPayload.toString()).toBe('fake-tgz-bytes') const otherPlatformResponse = await app!.inject(bundleRequest('@acme/piece-private', '0.0.1', tokenB)) - expect(otherPlatformResponse.statusCode).toBe(StatusCodes.NOT_FOUND) + expect(otherPlatformResponse.statusCode).toBe(StatusCodes.TEMPORARY_REDIRECT) + expect(otherPlatformResponse.headers.location).toContain('registry.npmjs.org') + expect(otherPlatformResponse.rawPayload.toString()).not.toContain('fake-tgz-bytes') }) it('streams an archive by archiveId for the owning platform and 404s for others', async () => { From 4a1d77ffe351ab96a97d25dee0f5933a47c6ce06 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Sun, 23 Aug 2026 11:36:41 +0300 Subject: [PATCH 3/3] feat(pieces): add QuickBooks Desktop (Conductor) piece (#14981) --- .../pieces-engine/building-pieces.md | 5 + bun.lock | 15 + .../.eslintrc.json | 47 +++ .../quickbooks-desktop-conductor/README.md | 93 ++++++ .../quickbooks-desktop-conductor/package.json | 20 ++ .../src/i18n/translation.json | 134 ++++++++ .../quickbooks-desktop-conductor/src/index.ts | 41 +++ .../src/lib/actions/create-bill.ts | 169 +++++++++++ .../src/lib/actions/create-invoice.ts | 149 +++++++++ .../src/lib/actions/list-items.ts | 124 ++++++++ .../src/lib/actions/query-transactions.ts | 167 ++++++++++ .../src/lib/actions/record-payment.ts | 287 ++++++++++++++++++ .../src/lib/actions/upsert-customer.ts | 178 +++++++++++ .../src/lib/actions/upsert-vendor.ts | 173 +++++++++++ .../src/lib/auth.ts | 52 ++++ .../src/lib/common/accounts.ts | 41 +++ .../src/lib/common/address.ts | 56 ++++ .../src/lib/common/client.ts | 113 +++++++ .../src/lib/common/dropdowns.ts | 55 ++++ .../src/lib/common/errors.ts | 102 +++++++ .../src/lib/common/invoices.ts | 42 +++ .../src/lib/common/items.ts | 68 +++++ .../src/lib/common/payments.ts | 39 +++ .../src/lib/common/polling.ts | 68 +++++ .../src/lib/output-schemas.ts | 150 +++++++++ .../lib/triggers/new-or-updated-invoice.ts | 68 +++++ .../src/lib/triggers/new-payment.ts | 69 +++++ .../tsconfig.json | 15 + .../tsconfig.lib.json | 13 + tsconfig.base.json | 3 + 30 files changed, 2556 insertions(+) create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/.eslintrc.json create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/README.md create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/package.json create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/i18n/translation.json create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/index.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-bill.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-invoice.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/list-items.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/query-transactions.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/record-payment.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-customer.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-vendor.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/auth.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/accounts.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/address.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/client.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/dropdowns.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/errors.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/invoices.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/items.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/payments.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/polling.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/output-schemas.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-or-updated-invoice.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-payment.ts create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/tsconfig.json create mode 100644 packages/pieces/community/quickbooks-desktop-conductor/tsconfig.lib.json diff --git a/brain/knowledge/pieces-engine/building-pieces.md b/brain/knowledge/pieces-engine/building-pieces.md index ca15f95f7d33..ef85061e294e 100644 --- a/brain/knowledge/pieces-engine/building-pieces.md +++ b/brain/knowledge/pieces-engine/building-pieces.md @@ -27,6 +27,11 @@ Authentication, triggers (polling/webhook), properties + validation, flow contro - **Porting postgres `new-row.ts` to another SQL piece: the `LIMIT 5` is cold-start only — do not carry it onto the resume branch.** `constructQuery` (`postgres/src/lib/triggers/new-row.ts:41`) has two shapes, and the asymmetry between them is load-bearing: the no-checkpoint branch seeds with `ORDER BY %I DESC LIMIT 5` (`:46,48`), while the resume branch is deliberately **unbounded** — `WHERE %I >= %L ORDER BY %I DESC`, no LIMIT (`:58,60`). It has to be, because `DedupeStrategy.LAST_ITEM` (`:17`) recovers the checkpoint by scanning *the page it just fetched* (`pieces/common/src/lib/polling/index.ts:99`, `items.findIndex((f) => f.id === lastItemId)`) and emits everything ahead of it. Bound the resume page and the checkpoint row can fall off the end, where `findIndex → -1` is read as "no checkpoint" and the entire page re-emits ([triggers.md](../flows-execution/triggers.md) has the same mechanic from the republish side). So a literal `LIMIT 5` → `TOP (5)` is a behaviour change, not a dialect translation — and the moment you *do* want a bounded resume page you are off `pollingHelper` altogether and owe a keyset cursor that carries its position in the store instead of recovering it by scanning: `microsoft-sql-server/src/lib/common/cursor.ts` is the worked example (`TOP (@limit)` on every page, versioned cursor, explicit tiebreaker key). Two more sharp edges if you copy this template: the item id is `orderValue + '|' + md5(JSON.stringify(row))` (`:24-28`), so **any edit to the checkpoint row changes its id and invalidates the checkpoint**, and `lastItem.split('|')[0]` (`:42`) truncates any order value containing a literal `|` — fine for timestamps and serial ids, wrong for ordering on a text column. - **Streaming a file *into* a piece is `Property.File({ streaming: true })`.** It resolves to an `ApStreamingFile` with `body: Readable` (pieces-framework ≥ 0.35.0, [000014](../../decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md)) and accepts a URL, a base64 data URL, the builder's file picker, or a previous step's file — a strict superset of a URL text field, with the fetch owned by the engine. `amazon-s3/upload-file.ts` and `subflows/stream-csv-to-flow.ts` are the references. Three things to know: the engine's `fileProcessor` swallows fetch failures and returns `null`, which for a `required: true` prop surfaces as the confusing `Expected file url or base64 with mimeType` validation error rather than a fetch error (so no `isNil` guard in your `run()` is needed — the action never starts); the engine's fetch has **no timeout**, so a source that connects then stalls burns `FLOW_TIMEOUT_SECONDS`; and `.pipe()` does not forward `'error'`, so you still need `file.body.on('error', ...)` or a mid-stream network drop becomes an uncaught exception in the sandbox. - **Streaming only removes *our* memory ceiling — check the destination's per-request cap before calling an upload action fixed.** A body that streams cleanly out of the sandbox still gets rejected whole by the API: Dropbox's `/2/files/upload` answers `409 {".tag": "payload_too_large"}` above 150 MB, Graph's simple `PUT …/content` above 250 MB. The tell that it's the service and not us is the shape — an endpoint-specific 409 with a documented error tag, and an axios/undici request echo whose `body` is just a `_readableState` blob (our stream, sent fine). The fix is a chunked upload session, not a bigger buffer: `dropbox/upload-file.ts` and `microsoft-onedrive/upload-file.ts` are the references, both chunking through the shared `streamUtils.readChunks({ readable, chunkSize })` from `@activepieces/pieces-common` — reuse it rather than writing a third stream chunker. Two rules that fall out of doing it: **route unknown-size sources through the session too** (`size` is best-effort and absent on chunked or compressed sources, so you cannot prove they fit — and the old fallback of buffering to learn the size is the OOM this streaming work exists to remove), and keep the chunk size a multiple of the service's preferred unit (4 MiB for Dropbox, 320 KiB for OneDrive). Chunk bodies are `Buffer`s, so unlike a one-shot stream body they keep `httpClient`'s retries. **Whether you can chunk an unknown-size source at all depends on how the session addresses its parts:** Dropbox's is offset-based (`cursor.offset`, no total ever declared) so it streams straight through, while Graph's wants the file's total length in every fragment's `Content-Range` — so `microsoft-sharepoint` and `microsoft-onedrive` must `readableToBuffer` once to learn the length, then re-wrap with `Readable.from` so both branches still take a stream. That buffer is the OOM this work removes, so it is a last resort, not the pattern: reach for the offset-based session whenever the API offers one. SharePoint's cap is generous enough (250 MB one-shot vs OneDrive's 4 MiB) that the buffer only ever runs for a size-less source. +- **On Windows, a new action/trigger name (or any metadata-shape change) needs the dev server process killed, not restarted.** `clearPieceModuleCache` — the only thing that busts the CommonJS `require()` cache backing dev piece metadata — is called exclusively from the chokidar watcher's rebuild handler (`dev-piece-watcher.ts`), and that watcher does not fire reliably on Windows for tool-made edits. A "normal restart" reuses the same PID (confirm with `netstat`/`Get-Process` bound to the dev port), so the server keeps serving the stale metadata. Find the PID bound to the dev API port and `Stop-Process -Id -Force`, then start fresh — every other change (prop text, logic inside `run()`) hot-reloads fine; only new action/trigger names or output-shape changes hit this. +- **`Property.Array`'s `properties` sub-schema never threads into its resolved `propsValue` type — confirmed in `packages/pieces/framework/src/lib/property/index.ts`.** `propsValue.someArrayProp` types as plain `unknown[]` regardless of what `properties` declares, so casting to the declared row type at the point of use is the only option; there is no framework-provided type-safe path around it. Document the cast in a comment so it doesn't read as an oversight on a later pass. +- **`Property.Dropdown` (dynamic single-select) cannot go inside `Property.Array`** — it's excluded from `ArraySubProps` in `packages/pieces/framework/src/lib/property/input/array-property.ts`. A line-item array that needs to reference another resource by id (e.g. "which item/account does this line use") can't put a searchable dropdown per row; resolve by exact name server-side in `run()` instead (a lookup helper keyed on the row's plain text field) rather than falling back to a raw-id text field. +- **Ungrouped props render after every declared `propertyGroups` section, not inline in prop-declaration order.** A "mode selector" prop that decides which of several sections is relevant (e.g. a payment-type toggle gating Accounts-Receivable vs Accounts-Payable fields) must get its own section declared *first* in `propertyGroups`, or it renders dead last — after the very fields it's supposed to gate. Caught via visual review, not build/lint. +- **`HttpRequest.queryParams` (`@activepieces/pieces-common`) is `Record` — one value per key, no array support** (confirmed in `query-params.ts`). A third-party API that wants a repeated param (`type=a&type=b`) rather than a comma-joined value can't be satisfied through `queryParams` alone. Fix: pre-encode the repeated params directly into `resourceUri`'s query string (`resourceUri: '/x?type=a&type=b'`) — `getUrl()` parses and preserves an existing query string on the URL before merging the `queryParams` object on top, so both coexist correctly. - **Every piece you touch in a PR needs a version bump, and CI only names the first one.** `validate-publishable-packages` runs `packagePrePublishChecks` (`tools/scripts/utils/package-pre-publish-checks.ts`) over every piece directory: if the piece's `package.json` version is already the npm `latest` **and** `git diff origin/main -- ` is non-empty, it throws `package version not incremented` — unless that piece's own `package.json` also changed, which is how a bump satisfies it. Two traps. The diff is against **`origin/main`, not the PR base**, so a stacked PR inherits every piece its base touched and must bump those too. And the checks run in `Promise.all` batches of 10, so the first thrown error kills the process — the log names one piece (`azure-ad`) when 27 are equally broken. Don't fix the named one and re-push; enumerate `git diff --name-only origin/main...HEAD | grep pieces/` and bump the whole set at once. Patch bump is the convention even for behaviour changes like added OAuth scopes. `packages/pieces/framework` and `packages/pieces/common` are exempt (explicit `notPublished` list in `validate-publishable-packages.ts` — pieces inline them at build time), as is everything outside `packages/pieces/`. The script is runnable locally, and takes ~3 min: `npx ts-node -r tsconfig-paths/register -P packages/server/engine/tsconfig.lib.json tools/scripts/validate-publishable-packages.ts`. ## Sharing & misc diff --git a/bun.lock b/bun.lock index f43e21adbcb7..2c97db8ce16d 100644 --- a/bun.lock +++ b/bun.lock @@ -7360,6 +7360,19 @@ "tslib": "2.6.2", }, }, + "packages/pieces/community/quickbooks-desktop-conductor": { + "name": "@activepieces/piece-quickbooks-desktop-conductor", + "version": "0.0.2", + "dependencies": { + "@activepieces/core-piece-types": "workspace:*", + "@activepieces/core-utils": "workspace:*", + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + }, + "devDependencies": { + "tslib": "2.6.2", + }, + }, "packages/pieces/community/quickbooks-sandbox": { "name": "@activepieces/piece-quickbooks-sandbox", "version": "0.1.0", @@ -12143,6 +12156,8 @@ "@activepieces/piece-quickbooks": ["@activepieces/piece-quickbooks@workspace:packages/pieces/community/quickbooks"], + "@activepieces/piece-quickbooks-desktop-conductor": ["@activepieces/piece-quickbooks-desktop-conductor@workspace:packages/pieces/community/quickbooks-desktop-conductor"], + "@activepieces/piece-quickbooks-sandbox": ["@activepieces/piece-quickbooks-sandbox@workspace:packages/pieces/community/quickbooks-sandbox"], "@activepieces/piece-quickzu": ["@activepieces/piece-quickzu@workspace:packages/pieces/community/quickzu"], diff --git a/packages/pieces/community/quickbooks-desktop-conductor/.eslintrc.json b/packages/pieces/community/quickbooks-desktop-conductor/.eslintrc.json new file mode 100644 index 000000000000..6f1536634f91 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/.eslintrc.json @@ -0,0 +1,47 @@ +{ + "extends": [ + "../../../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "lodash", + "lodash/*", + "@activepieces/core-*", + "@activepieces/server*", + "@activepieces/engine", + "@activepieces/shared" + ] + } + ] + } + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/README.md b/packages/pieces/community/quickbooks-desktop-conductor/README.md new file mode 100644 index 000000000000..6e576443abc2 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/README.md @@ -0,0 +1,93 @@ +# QuickBooks Desktop (via Conductor) + +Syncs invoices, bills, customers, vendors, and payments with **QuickBooks Desktop** — the +installed Windows application, not QuickBooks Online. QuickBooks Desktop has no cloud API of its +own, so this piece bridges through [Conductor](https://conductor.is), a paid intermediary that +talks to QuickBooks Desktop via its QuickBooks Web Connector (QBWC). + +Because of that bridge, every sync depends on **the QuickBooks Desktop machine being on, QuickBooks +running with the right company file open (or configured to open without it — see below), and the +Web Connector reachable.** This piece cannot see or change that from the Activepieces side — if the +tenant's machine is off, syncs simply don't happen until it's back. + +## Building + +Run `turbo run build --filter=@activepieces/piece-quickbooks-desktop-conductor` to build the piece. + +## Tenant onboarding — do this once per QuickBooks Desktop company file + +1. **Get a Conductor account and API key.** Conductor's secret key is account-wide, not + per-tenant — if you're onboarding multiple QuickBooks Desktop company files (e.g. one per + customer), they typically all live under one Conductor account, distinguished by **End-User ID** + (see step 3), not by separate secret keys. Confirm this fits your billing/isolation model before + onboarding many tenants onto one key — see **Multi-tenant considerations** below. +2. **Create an End-User in Conductor** for this specific QuickBooks Desktop company file (Conductor + dashboard → End-Users → New). This is what scopes every API call to the right company file. +3. **Connect QuickBooks Desktop to that End-User.** Conductor's dashboard walks the tenant through + downloading a `.qwc` file and importing it into the QuickBooks Web Connector (QBWC) — a small + utility that ships with QuickBooks Desktop. Importing the `.qwc` file makes QuickBooks Desktop + show a one-time authorization prompt. +4. **⚠️ Top setup mistake, and the top cause of "sync just stopped" tickets:** that authorization + prompt asks how much access to grant the Web Connector. **Choose the option that keeps access + even when QuickBooks Desktop isn't the active/foreground application** (commonly phrased "allow + access even if QuickBooks is not running" or similar — the exact wording depends on the + QuickBooks Desktop version). **Do not** choose the option that re-prompts every time, or one that + only grants access while QuickBooks is actively in use. + - **Why this matters so much**: picking the wrong option doesn't produce an error anywhere — + Conductor and Activepieces have no way to detect it from the outside. It just means QuickBooks + Desktop silently declines every sync until a human is physically at that machine, with + QuickBooks open, to click "yes" on a prompt nobody told them to expect. In practice this + surfaces as "nothing has synced in days" with no error in sight, which is expensive to + diagnose after the fact and cheap to prevent by getting this one prompt right during setup. + - *This exact prompt is native QuickBooks Desktop / Web Connector behavior, not something + Conductor or this piece controls — the precise wording varies by QuickBooks Desktop version. + Confirm the exact wording on the actual dialog during setup rather than trusting this + paraphrase; a hands-on pass against a real QuickBooks Desktop install is worth 5 minutes here.* +5. Once Conductor's dashboard shows the End-User as **Connected**, go to **Settings → API Keys** in + Conductor and copy the **Secret Key**, and copy this End-User's **End-User ID** + (starts with `end_usr_`) from the End-Users page. +6. In Activepieces, create a new connection for this piece and paste in the **Secret Key** and + **End-User ID** from steps 1 and 5. Activepieces validates the connection by calling Conductor's + health-check endpoint immediately — a failure here means Conductor itself is unreachable or the + key is wrong, not the QuickBooks Desktop machine specifically. + +**Multi-tenant considerations.** If one Conductor account/secret key ends up serving many tenants +(rather than one Conductor account per tenant), keep in mind: +- The **End-User ID is a per-tenant connection prop, never a step input** — this piece is built + that way on purpose, so a flow can't accidentally query the wrong tenant's data by having the + end-user ID typed or mapped in as a variable. +- **Every flow polling on a shared key adds load to that one key.** 85 tenants each polling both + triggers every ~5 minutes is roughly 85 concurrent-ish requests every cycle on one Conductor + account. This piece has no built-in throttling for that — if you're running at that scale, ask + Conductor directly about their rate limits for a single account before rolling out broadly. + +## Troubleshooting + +**Error message contains "QuickBooks Desktop connection failed" / code `QBD_CONNECTION_ERROR`.** +The bridge to that specific QuickBooks Desktop machine is down — almost always because the machine +is off, asleep, QuickBooks Desktop isn't running with the right company file open, or (see above) +the Web Connector authorization was set to prompt-every-time and nobody's there to approve it. This +piece treats this as a normal, expected condition for QuickBooks Desktop (not a bug) — actions throw +so the failed run is visible, and the two triggers simply produce zero results that poll cycle +rather than erroring, since "the machine is off tonight" isn't a flow failure worth alerting on. + +**A customer/vendor/item name isn't matching even though it exists in QuickBooks.** Name matching +is exact and case-sensitive against QuickBooks Desktop's own name field. Check for a trailing +space, a different capitalization, or a sub-account/sub-customer syntax mismatch (QuickBooks +Desktop uses `Parent:Child` for hierarchy). + +## Scope notes + +- **Record Payment** covers both money coming in (a customer payment, Accounts Receivable) and + money going out (a vendor bill payment by check or credit card, Accounts Payable) through one + action with a Payment Type selector — this is a deliberate choice for this piece, not a missing + feature; the underlying QuickBooks Desktop transactions are genuinely different endpoints under + the hood. +- **New Payment** (the trigger) only fires for customer payments (Accounts Receivable) — it does + **not** fire for vendor bill payments. If a flow needs to react to money going out, poll + **Query Transactions** on a schedule with `transactionTypes` set to the bill-payment types + instead. +- **New or Updated Invoice** fires on both creation and edits to an existing invoice (e.g. a + payment applied against it, changing its balance) — it is not create-only. A flow that should + only react to brand-new invoices should compare the invoice's `created_at` and `updated_at` + fields in its output. diff --git a/packages/pieces/community/quickbooks-desktop-conductor/package.json b/packages/pieces/community/quickbooks-desktop-conductor/package.json new file mode 100644 index 000000000000..955be5e333e1 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/package.json @@ -0,0 +1,20 @@ +{ + "name": "@activepieces/piece-quickbooks-desktop-conductor", + "version": "0.0.2", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.lib.json && cp package.json dist/", + "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", + "lint": "eslint 'src/**/*.ts'" + }, + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + "@activepieces/core-piece-types": "workspace:*", + "@activepieces/core-utils": "workspace:*" + }, + "devDependencies": { + "tslib": "2.6.2" + } +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/i18n/translation.json b/packages/pieces/community/quickbooks-desktop-conductor/src/i18n/translation.json new file mode 100644 index 000000000000..9a337e6ecd2c --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/i18n/translation.json @@ -0,0 +1,134 @@ +{ + "Sync invoices, bills, customers, vendors and payments with QuickBooks Desktop through the Conductor API bridge.": "Sync invoices, bills, customers, vendors and payments with QuickBooks Desktop through the Conductor API bridge.", + "Conductor Secret Key": "Conductor Secret Key", + "Conductor End-User ID": "Conductor End-User ID", + "From your Conductor dashboard, Settings → API Keys.": "From your Conductor dashboard, Settings → API Keys.", + "The end-user ID for this QuickBooks Desktop company file (starts with end_usr_).": "The end-user ID for this QuickBooks Desktop company file (starts with end_usr_).", + "\nThis piece connects to QuickBooks Desktop through [Conductor](https://conductor.is) — Activepieces\ncannot talk to QuickBooks Desktop directly, since it has no cloud API of its own.\n\n1. In your Conductor dashboard, go to **Settings → API Keys** and copy your **Secret Key**.\n2. Under **End-Users**, select (or create) the end-user connected to this QuickBooks Desktop\n company file and copy their **End-User ID** (starts with `end_usr_`).\n3. The end-user must have already completed Conductor's auth flow and i": "\nThis piece connects to QuickBooks Desktop through [Conductor](https://conductor.is) — Activepieces\ncannot talk to QuickBooks Desktop directly, since it has no cloud API of its own.\n\n1. In your Conductor dashboard, go to **Settings → API Keys** and copy your **Secret Key**.\n2. Under **End-Users**, select (or create) the end-user connected to this QuickBooks Desktop\n company file and copy their **End-User ID** (starts with `end_usr_`).\n3. The end-user must have already completed Conductor's auth flow and installed the QuickBooks\n Web Connector — Conductor's dashboard shows the connection as \"Connected\" once QuickBooks\n Desktop is reachable. If it shows \"Not connected,\" fix that in Conductor before connecting here.\n4. **Top setup mistake — read before finishing step 3:** when QuickBooks Desktop first asks to\n authorize the Web Connector, **choose the option that keeps access even when QuickBooks Desktop\n isn't running** (not \"ask every time\"). Picking the wrong option doesn't error — it just makes\n every sync silently do nothing until someone is sitting at the QuickBooks Desktop machine to\n approve it, which usually isn't noticed until a customer asks why nothing has synced in days.\n See this piece's README for the full walkthrough.\n", + "Upsert Customer": "Upsert Customer", + "Upsert Vendor": "Upsert Vendor", + "Create Invoice": "Create Invoice", + "Create Bill": "Create Bill", + "Record Payment": "Record Payment", + "Query Transactions": "Query Transactions", + "List Items": "List Items", + "Custom API Call": "Custom API Call", + "Creates a customer in QuickBooks Desktop, or updates it if one with the same name already exists.": "Creates a customer in QuickBooks Desktop, or updates it if one with the same name already exists.", + "Creates a vendor in QuickBooks Desktop, or updates it if one with the same name already exists.": "Creates a vendor in QuickBooks Desktop, or updates it if one with the same name already exists.", + "Creates an invoice for a customer in QuickBooks Desktop.": "Creates an invoice for a customer in QuickBooks Desktop.", + "Creates a vendor bill (accounts payable) in QuickBooks Desktop, expensed against one or more accounts.": "Creates a vendor bill (accounts payable) in QuickBooks Desktop, expensed against one or more accounts.", + "Records a customer payment against open invoices, or a vendor bill payment by check or credit card, in QuickBooks Desktop.": "Records a customer payment against open invoices, or a vendor bill payment by check or credit card, in QuickBooks Desktop.", + "Searches across all transaction types in QuickBooks Desktop (invoices, bills, payments, and more) with date and type filters.": "Searches across all transaction types in QuickBooks Desktop (invoices, bills, payments, and more) with date and type filters.", + "Lists Service and Non-Inventory items from QuickBooks Desktop, for finding the exact item name to use in Create Invoice's line items.": "Lists Service and Non-Inventory items from QuickBooks Desktop, for finding the exact item name to use in Create Invoice's line items.", + "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", + "Customer Name": "Customer Name", + "Company Name": "Company Name", + "Email": "Email", + "Phone": "Phone", + "Note": "Note", + "Address Line 1": "Address Line 1", + "City": "City", + "State": "State", + "Postal Code": "Postal Code", + "Country": "Country", + "Vendor Name": "Vendor Name", + "Customer": "Customer", + "Invoice Date": "Invoice Date", + "Due Date": "Due Date", + "Invoice Number": "Invoice Number", + "Memo": "Memo", + "Line Items": "Line Items", + "Vendor": "Vendor", + "Bill Date": "Bill Date", + "Bill Number": "Bill Number", + "Expense Lines": "Expense Lines", + "Payment Type": "Payment Type", + "Payment Date": "Payment Date", + "Amount": "Amount", + "Reference / Check Number": "Reference / Check Number", + "Apply to a specific invoice": "Apply to a specific invoice", + "Invoice": "Invoice", + "Bill": "Bill", + "Bank Account": "Bank Account", + "Credit Card Account": "Credit Card Account", + "Transaction Date From": "Transaction Date From", + "Transaction Date To": "Transaction Date To", + "Transaction Types": "Transaction Types", + "Payment Status": "Payment Status", + "Entity ID": "Entity ID", + "Max Results": "Max Results", + "Page Cursor": "Page Cursor", + "Name Contains": "Name Contains", + "Status": "Status", + "Max Results (per item type)": "Max Results (per item type)", + "Method": "Method", + "Headers": "Headers", + "Query Parameters": "Query Parameters", + "Body Type": "Body Type", + "Body": "Body", + "Response is Binary ?": "Response is Binary ?", + "No Error on Failure": "No Error on Failure", + "Timeout (in seconds)": "Timeout (in seconds)", + "Follow redirects": "Follow redirects", + "The exact name of the customer as it should appear in QuickBooks Desktop (max 41 characters). Used to find an existing customer — if one already exists with this exact name, it is updated instead of duplicated.": "The exact name of the customer as it should appear in QuickBooks Desktop (max 41 characters). Used to find an existing customer — if one already exists with this exact name, it is updated instead of duplicated.", + "The exact name of the vendor as it should appear in QuickBooks Desktop (max 41 characters, unique across all vendors). Used to find an existing vendor — if one already exists with this exact name, it is updated instead of duplicated.": "The exact name of the vendor as it should appear in QuickBooks Desktop (max 41 characters, unique across all vendors). Used to find an existing vendor — if one already exists with this exact name, it is updated instead of duplicated.", + "The customer this invoice is for. Only customers that already exist in QuickBooks Desktop appear here — use Upsert Customer first if the customer might not exist yet.": "The customer this invoice is for. Only customers that already exist in QuickBooks Desktop appear here — use Upsert Customer first if the customer might not exist yet.", + "When left blank, QuickBooks Desktop derives it from the customer's default payment terms.": "When left blank, QuickBooks Desktop derives it from the customer's default payment terms.", + "Optional reference number (max 11 characters). QuickBooks Desktop does not auto-generate one if left blank.": "Optional reference number (max 11 characters). QuickBooks Desktop does not auto-generate one if left blank.", + "Internal note. Appears in reports, not on the invoice sent to the customer.": "Internal note. Appears in reports, not on the invoice sent to the customer.", + "At least one line item is required.": "At least one line item is required.", + "The vendor this bill is from. Only vendors that already exist in QuickBooks Desktop appear here — use Upsert Vendor first if the vendor might not exist yet.": "The vendor this bill is from. Only vendors that already exist in QuickBooks Desktop appear here — use Upsert Vendor first if the vendor might not exist yet.", + "When left blank, QuickBooks Desktop derives it from the vendor's default payment terms.": "When left blank, QuickBooks Desktop derives it from the vendor's default payment terms.", + "Optional reference number (max 20 characters). QuickBooks Desktop does not auto-generate one if left blank.": "Optional reference number (max 20 characters). QuickBooks Desktop does not auto-generate one if left blank.", + "Internal note. Appears in the A/P register and reports, not sent to the vendor.": "Internal note. Appears in the A/P register and reports, not sent to the vendor.", + "At least one expense line is required. Each line expenses this bill against one Chart of Accounts entry (e.g. fuel, maintenance, insurance) — not a product/service item.": "At least one expense line is required. Each line expenses this bill against one Chart of Accounts entry (e.g. fuel, maintenance, insurance) — not a product/service item.", + "Total payment amount, e.g. \"500.00\".": "Total payment amount, e.g. \"500.00\".", + "Optional. Max 20 characters for a customer payment, max 11 for a bill payment (it's the check number for a check payment).": "Optional. Max 20 characters for a customer payment, max 11 for a bill payment (it's the check number for a check payment).", + "Required for Customer Payment only. The customer who made this payment.": "Required for Customer Payment only. The customer who made this payment.", + "Customer Payment only. When off, QuickBooks Desktop automatically applies the payment to an exact-amount match or the oldest open invoice.": "Customer Payment only. When off, QuickBooks Desktop automatically applies the payment to an exact-amount match or the oldest open invoice.", + "The specific open invoice this payment applies to.": "The specific open invoice this payment applies to.", + "Required for both Bill Payment types. The vendor being paid.": "Required for both Bill Payment types. The vendor being paid.", + "Required for both Bill Payment types. QuickBooks Desktop has no auto-apply for bill payments, so the specific open bill must be picked.": "Required for both Bill Payment types. QuickBooks Desktop has no auto-apply for bill payments, so the specific open bill must be picked.", + "Required for Bill Payment — Check only. The account funds are drawn from.": "Required for Bill Payment — Check only. The account funds are drawn from.", + "Required for Bill Payment — Credit Card only. The account charged.": "Required for Bill Payment — Credit Card only. The account charged.", + "Leave empty to search all types.": "Leave empty to search all types.", + "Leave empty to search regardless of payment status.": "Leave empty to search regardless of payment status.", + "Optional. A customer, vendor, or employee id to filter by — typically chained from another step's output (e.g. Upsert Customer's Customer ID), not typed by hand.": "Optional. A customer, vendor, or employee id to filter by — typically chained from another step's output (e.g. Upsert Customer's Customer ID), not typed by hand.", + "Results per page (1–150).": "Results per page (1–150).", + "Optional. Pass the Next Cursor from a previous call's output to fetch the next page.": "Optional. Pass the Next Cursor from a previous call's output to fetch the next page.", + "Search by a substring of the item name. Leave empty to list all items.": "Search by a substring of the item name. Leave empty to list all items.", + "Results per item type — up to twice this many total, since Service and Non-Inventory items are searched together (1–150).": "Results per item type — up to twice this many total, since Service and Non-Inventory items are searched together (1–150).", + "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "Customer Payment (Accounts Receivable)": "Customer Payment (Accounts Receivable)", + "Vendor Bill Payment — Check (Accounts Payable)": "Vendor Bill Payment — Check (Accounts Payable)", + "Vendor Bill Payment — Credit Card (Accounts Payable)": "Vendor Bill Payment — Credit Card (Accounts Payable)", + "Customer Payment": "Customer Payment", + "Bill Payment (Check)": "Bill Payment (Check)", + "Bill Payment (Credit Card)": "Bill Payment (Credit Card)", + "Purchase Order": "Purchase Order", + "Credit Memo": "Credit Memo", + "Sales Receipt": "Sales Receipt", + "Estimate": "Estimate", + "Check": "Check", + "Transfer": "Transfer", + "Open (unpaid)": "Open (unpaid)", + "Closed (paid)": "Closed (paid)", + "Either": "Either", + "Active": "Active", + "Inactive": "Inactive", + "GET": "GET", + "POST": "POST", + "PATCH": "PATCH", + "PUT": "PUT", + "DELETE": "DELETE", + "HEAD": "HEAD", + "None": "None", + "JSON": "JSON", + "Form Data": "Form Data", + "Raw": "Raw", + "New or Updated Invoice": "New or Updated Invoice", + "New Payment": "New Payment", + "Fires when an invoice is created or updated (e.g. line items, balance, or payment status changed) in QuickBooks Desktop. One event per invoice change, not create-only.": "Fires when an invoice is created or updated (e.g. line items, balance, or payment status changed) in QuickBooks Desktop. One event per invoice change, not create-only.", + "Fires once when a new customer payment is recorded (Accounts Receivable) in QuickBooks Desktop — a \"Receive Payment\" transaction, e.g. from the Record Payment action's Customer Payment mode. Create-only: editing an existing payment does not re-fire it. Vendor bill payments (Accounts Payable) do not fire this trigger.": "Fires once when a new customer payment is recorded (Accounts Receivable) in QuickBooks Desktop — a \"Receive Payment\" transaction, e.g. from the Record Payment action's Customer Payment mode. Create-only: editing an existing payment does not re-fire it. Vendor bill payments (Accounts Payable) do not fire this trigger." +} \ No newline at end of file diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/index.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/index.ts new file mode 100644 index 000000000000..a09d7711c5d9 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/index.ts @@ -0,0 +1,41 @@ +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from './lib/auth'; +import { upsertCustomerAction } from './lib/actions/upsert-customer'; +import { upsertVendorAction } from './lib/actions/upsert-vendor'; +import { createInvoiceAction } from './lib/actions/create-invoice'; +import { createBillAction } from './lib/actions/create-bill'; +import { recordPaymentAction } from './lib/actions/record-payment'; +import { queryTransactionsAction } from './lib/actions/query-transactions'; +import { listItemsAction } from './lib/actions/list-items'; +import { newOrUpdatedInvoiceTrigger } from './lib/triggers/new-or-updated-invoice'; +import { newPaymentTrigger } from './lib/triggers/new-payment'; + +export const quickbooksDesktopConductor = createPiece({ + displayName: 'QuickBooks Desktop (via Conductor)', + description: + 'Sync invoices, bills, customers, vendors and payments with QuickBooks Desktop through the Conductor API bridge.', + auth: quickbooksDesktopConductorAuth, + minimumSupportedRelease: '0.87.0', + logoUrl: 'https://cdn.activepieces.com/pieces/quickbooks.png', + categories: [PieceCategory.ACCOUNTING], + authors: ['OdaiAhmed99'], + actions: [ + upsertCustomerAction, + upsertVendorAction, + createInvoiceAction, + createBillAction, + recordPaymentAction, + queryTransactionsAction, + listItemsAction, + createCustomApiCallAction({ + auth: quickbooksDesktopConductorAuth, + baseUrl: () => 'https://api.conductor.is/v1', + authMapping: async (auth) => ({ + Authorization: `Bearer ${auth.props.secretKey}`, + 'Conductor-End-User-Id': auth.props.endUserId, + }), + }), + ], + triggers: [newOrUpdatedInvoiceTrigger, newPaymentTrigger], +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-bill.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-bill.ts new file mode 100644 index 000000000000..f170aca0f1b8 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-bill.ts @@ -0,0 +1,169 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, withRecordLockRetry, ConductorAuth } from '../common/client'; +import { resolveAccountIdByName } from '../common/accounts'; +import { vendorIdDropdown } from '../common/dropdowns'; +import { createBillActionOutputSchema } from '../output-schemas'; + +const MAX_REF_NUMBER_LENGTH = 20; + +type ConductorBill = { + id: string; + transactionDate: string; + dueDate: string | null; + refNumber: string | null; + memo: string | null; + // Vendor references use `fullName`, even though the vendor resource itself only has `name`. + // And it's `openAmount` here, not `balanceRemaining` — that's the invoice field name. + vendor: { id: string; fullName: string } | null; + amountDue: string; + openAmount: string; + isPaid: boolean; + expenseLines: unknown[]; + revisionNumber: string; + createdAt: string; + updatedAt: string; +}; + +type ExpenseLineInput = { + accountName: string; + amount: string; + memo?: string; +}; + +function toDateOnly(isoDateTime: string): string { + return isoDateTime.split('T')[0]; +} + +function flattenBill(bill: ConductorBill) { + return { + id: bill.id, + transaction_date: bill.transactionDate, + due_date: bill.dueDate, + ref_number: bill.refNumber, + memo: bill.memo, + vendor_id: bill.vendor?.id ?? null, + vendor_name: bill.vendor?.fullName ?? null, + amount_due: bill.amountDue, + balance_remaining: bill.openAmount, + is_paid: bill.isPaid, + line_count: bill.expenseLines?.length ?? 0, + revision_number: bill.revisionNumber, + created_at: bill.createdAt, + updated_at: bill.updatedAt, + }; +} + +export const createBillAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'create_bill', + classification: 'WRITE', + displayName: 'Create Bill', + description: 'Creates a vendor bill (accounts payable) in QuickBooks Desktop, expensed against one or more accounts.', + audience: 'both', + aiMetadata: { + description: + 'Record a new bill from an existing vendor, expensed against one or more Chart of Accounts entries (fuel, maintenance, insurance, etc.) — this is the accounts-payable expense-line shape, not an item/inventory purchase. Not idempotent — each call creates a new bill, so retries duplicate it; use Query Transactions first to check whether an equivalent bill already exists.', + idempotent: false, + }, + outputSchema: createBillActionOutputSchema, + props: { + vendorId: vendorIdDropdown({ + required: true, + description: 'The vendor this bill is from. Only vendors that already exist in QuickBooks Desktop appear here — use Upsert Vendor first if the vendor might not exist yet.', + }), + transactionDate: Property.DateTime({ + displayName: 'Bill Date', + required: true, + }), + dueDate: Property.DateTime({ + displayName: 'Due Date', + description: 'When left blank, QuickBooks Desktop derives it from the vendor\'s default payment terms.', + required: false, + }), + refNumber: Property.ShortText({ + displayName: 'Bill Number', + description: `Optional reference number (max ${MAX_REF_NUMBER_LENGTH} characters). QuickBooks Desktop does not auto-generate one if left blank.`, + required: false, + }), + memo: Property.LongText({ + displayName: 'Memo', + description: 'Internal note. Appears in the A/P register and reports, not sent to the vendor.', + required: false, + }), + expenseLines: Property.Array({ + displayName: 'Expense Lines', + description: 'At least one expense line is required. Each line expenses this bill against one Chart of Accounts entry (e.g. fuel, maintenance, insurance) — not a product/service item.', + required: true, + properties: { + accountName: Property.ShortText({ + displayName: 'Account Name', + description: 'The exact Chart of Accounts name in QuickBooks Desktop, e.g. "Automobile Expense" or "Expenses:Fuel" for a sub-account.', + required: true, + }), + amount: Property.ShortText({ + displayName: 'Amount', + description: 'The amount to expense to this account, e.g. "450.00".', + required: true, + }), + memo: Property.ShortText({ + displayName: 'Line Memo', + required: false, + }), + }, + }), + }, + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + // Same Property.Array typing gap as create-invoice.ts's lineItems. + const expenseLineInputs = propsValue.expenseLines as ExpenseLineInput[]; + if (expenseLineInputs.length === 0) { + throw new Error('At least one expense line is required to create a bill.'); + } + + if (propsValue.refNumber && propsValue.refNumber.length > MAX_REF_NUMBER_LENGTH) { + throw new Error( + `Bill Number must be ${MAX_REF_NUMBER_LENGTH} characters or fewer (QuickBooks Desktop's limit) — "${propsValue.refNumber}" is ${propsValue.refNumber.length}.` + ); + } + + const expenseLines = await Promise.all( + expenseLineInputs.map(async (line) => { + const accountId = await resolveAccountIdByName({ auth, name: line.accountName }); + return { + accountId, + amount: line.amount, + ...spreadIfDefined('memo', line.memo), + }; + }) + ); + + const body = { + vendorId: propsValue.vendorId, + transactionDate: toDateOnly(propsValue.transactionDate), + ...spreadIfDefined('dueDate', propsValue.dueDate ? toDateOnly(propsValue.dueDate) : undefined), + ...spreadIfDefined('refNumber', propsValue.refNumber), + ...spreadIfDefined('memo', propsValue.memo), + expenseLines, + }; + + const createdBill = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/bills', + body, + // This creates a new bill — see client.ts's `request` doc on why creates opt out of retry. + safeToRetry: false, + }) + ); + return flattenBill(createdBill); + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-invoice.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-invoice.ts new file mode 100644 index 000000000000..8a8566d4cb7b --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/create-invoice.ts @@ -0,0 +1,149 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, withRecordLockRetry, ConductorAuth } from '../common/client'; +import { resolveItemIdByName } from '../common/items'; +import { customerIdDropdown } from '../common/dropdowns'; +import { ConductorInvoice, flattenInvoice } from '../common/invoices'; +import { createInvoiceActionOutputSchema } from '../output-schemas'; + +const MAX_REF_NUMBER_LENGTH = 11; + +type LineItemInput = { + itemName: string; + description?: string; + quantity?: number; + rate?: string; + amount?: string; +}; + +function toDateOnly(isoDateTime: string): string { + return isoDateTime.split('T')[0]; +} + +export const createInvoiceAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'create_invoice', + classification: 'WRITE', + displayName: 'Create Invoice', + description: 'Creates an invoice for a customer in QuickBooks Desktop.', + audience: 'both', + aiMetadata: { + description: + 'Create a new invoice for an existing QuickBooks Desktop customer, with one or more line items. Each line item references a Service or Non-Inventory item by its exact name. Not idempotent — each call creates a new invoice, so retries duplicate it; use Query Transactions first to check whether an equivalent invoice already exists.', + idempotent: false, + }, + outputSchema: createInvoiceActionOutputSchema, + props: { + customerId: customerIdDropdown({ + required: true, + description: 'The customer this invoice is for. Only customers that already exist in QuickBooks Desktop appear here — use Upsert Customer first if the customer might not exist yet.', + }), + transactionDate: Property.DateTime({ + displayName: 'Invoice Date', + required: true, + }), + dueDate: Property.DateTime({ + displayName: 'Due Date', + description: 'When left blank, QuickBooks Desktop derives it from the customer\'s default payment terms.', + required: false, + }), + refNumber: Property.ShortText({ + displayName: 'Invoice Number', + description: `Optional reference number (max ${MAX_REF_NUMBER_LENGTH} characters). QuickBooks Desktop does not auto-generate one if left blank.`, + required: false, + }), + memo: Property.LongText({ + displayName: 'Memo', + description: 'Internal note. Appears in reports, not on the invoice sent to the customer.', + required: false, + }), + lineItems: Property.Array({ + displayName: 'Line Items', + description: 'At least one line item is required.', + required: true, + properties: { + itemName: Property.ShortText({ + displayName: 'Item Name', + description: 'The exact name of a Service or Non-Inventory item in QuickBooks Desktop (Lists > Item List).', + required: true, + }), + description: Property.ShortText({ + displayName: 'Description', + required: false, + }), + quantity: Property.Number({ + displayName: 'Quantity', + required: false, + }), + rate: Property.ShortText({ + displayName: 'Rate', + description: 'Price per unit, e.g. "125.00". Ignored if Amount is set.', + required: false, + }), + amount: Property.ShortText({ + displayName: 'Amount', + description: 'Total for this line, e.g. "500.00". Calculated from Quantity × Rate if left blank.', + required: false, + }), + }, + }), + }, + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + // Property.Array always resolves to `unknown[]` — its `properties` sub-schema doesn't thread + // through to the propsValue type — so this cast is unavoidable. The form above guarantees the + // actual shape. + const lineItemInputs = propsValue.lineItems as LineItemInput[]; + if (lineItemInputs.length === 0) { + throw new Error('At least one line item is required to create an invoice.'); + } + + if (propsValue.refNumber && propsValue.refNumber.length > MAX_REF_NUMBER_LENGTH) { + throw new Error( + `Invoice Number must be ${MAX_REF_NUMBER_LENGTH} characters or fewer (QuickBooks Desktop's limit) — "${propsValue.refNumber}" is ${propsValue.refNumber.length}.` + ); + } + + const lines = await Promise.all( + lineItemInputs.map(async (line) => { + const itemId = await resolveItemIdByName({ auth, name: line.itemName }); + return { + itemId, + ...spreadIfDefined('description', line.description), + ...spreadIfDefined('quantity', line.quantity), + ...spreadIfDefined('rate', line.rate), + ...spreadIfDefined('amount', line.amount), + }; + }) + ); + + const body = { + customerId: propsValue.customerId, + transactionDate: toDateOnly(propsValue.transactionDate), + ...spreadIfDefined('dueDate', propsValue.dueDate ? toDateOnly(propsValue.dueDate) : undefined), + ...spreadIfDefined('refNumber', propsValue.refNumber), + ...spreadIfDefined('memo', propsValue.memo), + lines, + }; + + const createdInvoice = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/invoices', + body, + // This creates a new invoice — Conductor has no idempotency key, so a blind transport + // retry on a lost response would create a duplicate. See client.ts's `request` doc. + safeToRetry: false, + }) + ); + return flattenInvoice(createdInvoice); + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/list-items.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/list-items.ts new file mode 100644 index 000000000000..5b48892489d8 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/list-items.ts @@ -0,0 +1,124 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, ConductorAuth } from '../common/client'; +import { listItemsActionOutputSchema } from '../output-schemas'; + +const MAX_PAGE_SIZE = 150; + +type ItemType = 'service' | 'non_inventory'; + +type ConductorItem = { + id: string; + name: string; + fullName: string; + isActive: boolean; +}; + +type ConductorItemListResponse = { + data: ConductorItem[]; + hasMore: boolean; +}; + +function flattenItem({ item, itemType }: { item: ConductorItem; itemType: ItemType }) { + return { + id: item.id, + name: item.name, + full_name: item.fullName, + item_type: itemType, + is_active: item.isActive, + }; +} + +export const listItemsAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'list_items', + classification: 'SEARCH', + displayName: 'List Items', + description: 'Lists Service and Non-Inventory items from QuickBooks Desktop, for finding the exact item name to use in Create Invoice\'s line items.', + audience: 'both', + aiMetadata: { + description: + 'Search QuickBooks Desktop\'s Service and Non-Inventory items by name — the two item types Create Invoice\'s line items accept. Use this to find the exact item name before calling Create Invoice, instead of guessing; typing an item name Create Invoice can\'t find fails with a clear error naming the bad value, but this avoids that round-trip. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listItemsActionOutputSchema, + props: { + nameContains: Property.ShortText({ + displayName: 'Name Contains', + description: 'Search by a substring of the item name. Leave empty to list all items.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + defaultValue: 'active', + options: { + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + { label: 'Either', value: 'all' }, + ], + }, + }), + limit: Property.Number({ + displayName: 'Max Results (per item type)', + description: `Results per item type — up to twice this many total, since Service and Non-Inventory items are searched together (1–${MAX_PAGE_SIZE}).`, + required: false, + defaultValue: MAX_PAGE_SIZE, + display: 'stepper', + min: 1, + max: MAX_PAGE_SIZE, + }), + }, + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + if (propsValue.limit && (propsValue.limit < 1 || propsValue.limit > MAX_PAGE_SIZE)) { + throw new Error(`Max Results must be between 1 and ${MAX_PAGE_SIZE} — got ${propsValue.limit}.`); + } + + const queryParams: Record = { + limit: String(propsValue.limit ?? MAX_PAGE_SIZE), + ...spreadIfDefined('nameContains', propsValue.nameContains), + ...spreadIfDefined('status', propsValue.status), + }; + + // Same two types, same fallback order, as resolveItemIdByName (common/items.ts) — this + // action exists so a flow builder can find the exact name that lookup expects, instead of + // typing one blind into Create Invoice's line items. + const [serviceItems, nonInventoryItems] = await Promise.all([ + conductorClient.request({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/service-items', + queryParams, + }), + conductorClient.request({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/non-inventory-items', + queryParams, + }), + ]); + + const items = [ + ...serviceItems.data.map((item) => flattenItem({ item, itemType: 'service' })), + ...nonInventoryItems.data.map((item) => flattenItem({ item, itemType: 'non_inventory' })), + ]; + + return { + items, + count: items.length, + // True if either item type has more results beyond this call's limit — this action + // doesn't paginate across the two merged types, so narrow the search instead (e.g. via + // Name Contains) rather than expecting more pages. + has_more: serviceItems.hasMore || nonInventoryItems.hasMore, + }; + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/query-transactions.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/query-transactions.ts new file mode 100644 index 000000000000..c6db3ba826ab --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/query-transactions.ts @@ -0,0 +1,167 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, ConductorAuth } from '../common/client'; +import { queryTransactionsActionOutputSchema } from '../output-schemas'; + +const MAX_PAGE_SIZE = 150; + +type ConductorTransaction = { + transactionType: string; + transactionId: string; + transactionDate: string; + refNumber: string | null; + amount: string; + memo: string | null; + entity: { id: string; fullName: string } | null; + account: { id: string; fullName: string } | null; + createdAt: string; + updatedAt: string; +}; + +function toDateOnly(isoDateTime: string): string { + return isoDateTime.split('T')[0]; +} + +function flattenTransaction(transaction: ConductorTransaction) { + return { + transaction_type: transaction.transactionType, + transaction_id: transaction.transactionId, + transaction_date: transaction.transactionDate, + ref_number: transaction.refNumber, + amount: transaction.amount, + memo: transaction.memo, + entity_id: transaction.entity?.id ?? null, + entity_name: transaction.entity?.fullName ?? null, + account_id: transaction.account?.id ?? null, + account_name: transaction.account?.fullName ?? null, + created_at: transaction.createdAt, + updated_at: transaction.updatedAt, + }; +} + +export const queryTransactionsAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'query_transactions', + classification: 'SEARCH', + displayName: 'Query Transactions', + description: 'Searches across all transaction types in QuickBooks Desktop (invoices, bills, payments, and more) with date and type filters.', + audience: 'both', + aiMetadata: { + description: + 'Search QuickBooks Desktop transactions across all types (invoice, bill, receive_payment, bill_payment_check, bill_payment_credit_card, and more) with date-range and payment-status filters. Only common fields are returned (id, type, date, amount, entity, account) — use the type-specific action or Custom API Call for full line-item detail. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: queryTransactionsActionOutputSchema, + props: { + transactionDateFrom: Property.DateTime({ + displayName: 'Transaction Date From', + required: false, + }), + transactionDateTo: Property.DateTime({ + displayName: 'Transaction Date To', + required: false, + }), + transactionTypes: Property.StaticMultiSelectDropdown({ + displayName: 'Transaction Types', + description: 'Leave empty to search all types.', + required: false, + options: { + options: [ + { label: 'Invoice', value: 'invoice' }, + { label: 'Bill', value: 'bill' }, + { label: 'Customer Payment', value: 'receive_payment' }, + { label: 'Bill Payment (Check)', value: 'bill_payment_check' }, + { label: 'Bill Payment (Credit Card)', value: 'bill_payment_credit_card' }, + { label: 'Purchase Order', value: 'purchase_order' }, + { label: 'Credit Memo', value: 'credit_memo' }, + { label: 'Sales Receipt', value: 'sales_receipt' }, + { label: 'Estimate', value: 'estimate' }, + { label: 'Check', value: 'check' }, + { label: 'Transfer', value: 'transfer' }, + ], + }, + }), + paymentStatus: Property.StaticDropdown({ + displayName: 'Payment Status', + description: 'Leave empty to search regardless of payment status.', + required: false, + options: { + options: [ + { label: 'Open (unpaid)', value: 'open' }, + { label: 'Closed (paid)', value: 'closed' }, + { label: 'Either', value: 'either' }, + ], + }, + }), + entityId: Property.ShortText({ + displayName: 'Entity ID', + description: 'Optional. A customer, vendor, or employee id to filter by — typically chained from another step\'s output (e.g. Upsert Customer\'s Customer ID), not typed by hand.', + required: false, + }), + limit: Property.Number({ + displayName: 'Max Results', + description: `Results per page (1–${MAX_PAGE_SIZE}).`, + required: false, + defaultValue: MAX_PAGE_SIZE, + display: 'stepper', + min: 1, + max: MAX_PAGE_SIZE, + }), + cursor: Property.ShortText({ + displayName: 'Page Cursor', + description: 'Optional. Pass the Next Cursor from a previous call\'s output to fetch the next page.', + required: false, + advanced: true, + }), + }, + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + if (propsValue.limit && (propsValue.limit < 1 || propsValue.limit > MAX_PAGE_SIZE)) { + throw new Error(`Max Results must be between 1 and ${MAX_PAGE_SIZE} — got ${propsValue.limit}.`); + } + + const queryParams: Record = { + limit: String(propsValue.limit ?? MAX_PAGE_SIZE), + ...spreadIfDefined('transactionDateFrom', propsValue.transactionDateFrom ? toDateOnly(propsValue.transactionDateFrom) : undefined), + ...spreadIfDefined('transactionDateTo', propsValue.transactionDateTo ? toDateOnly(propsValue.transactionDateTo) : undefined), + ...spreadIfDefined('paymentStatus', propsValue.paymentStatus), + ...spreadIfDefined('entityIds', propsValue.entityId), + ...spreadIfDefined('cursor', propsValue.cursor), + }; + + // queryParams only takes one value per key, but Conductor wants transactionTypes repeated, + // not comma-joined — so it's pre-encoded straight into the URL instead. This works because + // the client's getUrl preserves any query string already on the URL before merging queryParams. + const transactionTypesQuery = (propsValue.transactionTypes ?? []) + .map((type) => `transactionTypes=${encodeURIComponent(type)}`) + .join('&'); + const resourceUri = transactionTypesQuery + ? `/quickbooks-desktop/transactions?${transactionTypesQuery}` + : '/quickbooks-desktop/transactions'; + + const response = await conductorClient.request<{ + data: ConductorTransaction[]; + nextCursor: string | null; + hasMore: boolean; + }>({ + auth, + method: HttpMethod.GET, + resourceUri, + queryParams, + }); + + return { + transactions: response.data.map(flattenTransaction), + count: response.data.length, + next_cursor: response.nextCursor, + has_more: response.hasMore, + }; + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/record-payment.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/record-payment.ts new file mode 100644 index 000000000000..f5b860ba5f7b --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/record-payment.ts @@ -0,0 +1,287 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, withRecordLockRetry, ConductorAuth } from '../common/client'; +import { customerIdDropdown, vendorIdDropdown } from '../common/dropdowns'; +import { ConductorPaymentResult, isPaymentType, flattenPayment } from '../common/payments'; +import { recordPaymentActionOutputSchema } from '../output-schemas'; + +const CUSTOMER_PAYMENT_MAX_REF_LENGTH = 20; +const BILL_PAYMENT_MAX_REF_LENGTH = 11; + +function toDateOnly(isoDateTime: string): string { + return isoDateTime.split('T')[0]; +} + +export const recordPaymentAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'record_payment', + classification: 'WRITE', + displayName: 'Record Payment', + description: 'Records a customer payment against open invoices, or a vendor bill payment by check or credit card, in QuickBooks Desktop.', + audience: 'both', + aiMetadata: { + description: + 'Record a payment in QuickBooks Desktop — either a customer payment against open invoices (Accounts Receivable) or a vendor bill payment by check or credit card (Accounts Payable), chosen via Payment Type. Not idempotent — each call records a new payment, so retries duplicate it.', + idempotent: false, + }, + outputSchema: recordPaymentActionOutputSchema, + props: { + paymentType: Property.StaticDropdown({ + displayName: 'Payment Type', + required: true, + options: { + options: [ + { label: 'Customer Payment (Accounts Receivable)', value: 'customer_payment' }, + { label: 'Vendor Bill Payment — Check (Accounts Payable)', value: 'bill_payment_check' }, + { label: 'Vendor Bill Payment — Credit Card (Accounts Payable)', value: 'bill_payment_credit_card' }, + ], + }, + }), + transactionDate: Property.DateTime({ + displayName: 'Payment Date', + required: true, + }), + amount: Property.ShortText({ + displayName: 'Amount', + description: 'Total payment amount, e.g. "500.00".', + required: true, + }), + refNumber: Property.ShortText({ + displayName: 'Reference / Check Number', + description: `Optional. Max ${CUSTOMER_PAYMENT_MAX_REF_LENGTH} characters for a customer payment, max ${BILL_PAYMENT_MAX_REF_LENGTH} for a bill payment (it's the check number for a check payment).`, + required: false, + }), + memo: Property.LongText({ + displayName: 'Memo', + required: false, + }), + customerId: customerIdDropdown({ + required: false, + description: 'Required for Customer Payment only. The customer who made this payment.', + }), + applyToSpecificInvoice: Property.Checkbox({ + displayName: 'Apply to a specific invoice', + description: 'Customer Payment only. When off, QuickBooks Desktop automatically applies the payment to an exact-amount match or the oldest open invoice.', + required: false, + defaultValue: false, + reveals: ['invoiceId'], + }), + invoiceId: Property.Dropdown({ + displayName: 'Invoice', + description: 'The specific open invoice this payment applies to.', + auth: quickbooksDesktopConductorAuth, + required: false, + refreshers: ['customerId'], + options: async ({ auth, customerId }) => { + if (!auth || typeof customerId !== 'string' || customerId.length === 0) { + return { disabled: true, placeholder: 'Select a customer first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ + data: { id: string; refNumber: string | null; transactionDate: string; balanceRemaining: string }[]; + }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/invoices', + queryParams: { customerIds: customerId, paymentStatus: 'not_paid' }, + }); + return { + disabled: false, + options: response.data.map((inv) => ({ + label: `${inv.refNumber ?? inv.id} — $${inv.balanceRemaining} due (${inv.transactionDate})`, + value: inv.id, + })), + }; + }, + }), + vendorId: vendorIdDropdown({ + required: false, + description: 'Required for both Bill Payment types. The vendor being paid.', + }), + billId: Property.Dropdown({ + displayName: 'Bill', + description: 'Required for both Bill Payment types. QuickBooks Desktop has no auto-apply for bill payments, so the specific open bill must be picked.', + auth: quickbooksDesktopConductorAuth, + required: false, + refreshers: ['vendorId'], + options: async ({ auth, vendorId }) => { + if (!auth || typeof vendorId !== 'string' || vendorId.length === 0) { + return { disabled: true, placeholder: 'Select a vendor first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ + data: { id: string; refNumber: string | null; transactionDate: string; amountDue: string }[]; + }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/bills', + queryParams: { vendorIds: vendorId, paymentStatus: 'not_paid' }, + }); + return { + disabled: false, + options: response.data.map((bill) => ({ + label: `${bill.refNumber ?? bill.id} — $${bill.amountDue} due (${bill.transactionDate})`, + value: bill.id, + })), + }; + }, + }), + bankAccountId: Property.Dropdown({ + displayName: 'Bank Account', + description: 'Required for Bill Payment — Check only. The account funds are drawn from.', + auth: quickbooksDesktopConductorAuth, + required: false, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, placeholder: 'Connect your account first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ data: { id: string; fullName: string }[] }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/accounts', + queryParams: { accountType: 'bank' }, + }); + return { disabled: false, options: response.data.map((a) => ({ label: a.fullName, value: a.id })) }; + }, + }), + creditCardAccountId: Property.Dropdown({ + displayName: 'Credit Card Account', + description: 'Required for Bill Payment — Credit Card only. The account charged.', + auth: quickbooksDesktopConductorAuth, + required: false, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, placeholder: 'Connect your account first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ data: { id: string; fullName: string }[] }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/accounts', + queryParams: { accountType: 'credit_card' }, + }); + return { disabled: false, options: response.data.map((a) => ({ label: a.fullName, value: a.id })) }; + }, + }), + }, + propertyGroups: [ + // Declared first on purpose: ungrouped props render after every declared section, so + // paymentType — the field that decides which sections below actually matter — would + // otherwise land dead last in the form. + { key: 'type', display: 'section', label: 'Payment Type', props: ['paymentType'] }, + { key: 'details', display: 'section', label: 'Payment Details', props: ['transactionDate', 'amount', 'refNumber', 'memo'] }, + { key: 'ar', display: 'section', label: 'Customer Payment (Accounts Receivable)', props: ['customerId', 'applyToSpecificInvoice', 'invoiceId'] }, + { key: 'ap', display: 'section', label: 'Vendor Bill Payment (Accounts Payable)', props: ['vendorId', 'billId', 'bankAccountId', 'creditCardAccountId'] }, + ], + async run(context) { + const { propsValue } = context; + if (!isPaymentType(propsValue.paymentType)) { + throw new Error(`Unexpected Payment Type: "${propsValue.paymentType}".`); + } + const paymentType = propsValue.paymentType; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + const maxRefLength = paymentType === 'customer_payment' ? CUSTOMER_PAYMENT_MAX_REF_LENGTH : BILL_PAYMENT_MAX_REF_LENGTH; + if (propsValue.refNumber && propsValue.refNumber.length > maxRefLength) { + throw new Error( + `Reference/Check Number must be ${maxRefLength} characters or fewer for this payment type — "${propsValue.refNumber}" is ${propsValue.refNumber.length}.` + ); + } + + // `totalAmount` only belongs on receive-payments — the two bill-payment endpoints reject it + // and derive their total from applyToTransactions[].paymentAmount instead, so it's added + // per-branch below rather than in this shared body. + const commonBody = { + transactionDate: toDateOnly(propsValue.transactionDate), + ...spreadIfDefined('refNumber', propsValue.refNumber), + ...spreadIfDefined('memo', propsValue.memo), + }; + + if (paymentType === 'customer_payment') { + if (!propsValue.customerId) { + throw new Error('Customer is required for a Customer Payment.'); + } + if (propsValue.applyToSpecificInvoice && !propsValue.invoiceId) { + throw new Error('Invoice is required when "Apply to a specific invoice" is on.'); + } + const body = { + customerId: propsValue.customerId, + totalAmount: propsValue.amount, + ...commonBody, + ...(propsValue.applyToSpecificInvoice + ? { applyToTransactions: [{ transactionId: propsValue.invoiceId, paymentAmount: propsValue.amount }] } + : { isAutoApply: true }), + }; + const result = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/receive-payments', + body, + // This creates a new payment — see client.ts's `request` doc on why creates opt out of retry. + safeToRetry: false, + }) + ); + return flattenPayment({ payment: result, paymentType }); + } + + if (!propsValue.vendorId) { + throw new Error('Vendor is required for a Bill Payment.'); + } + if (!propsValue.billId) { + throw new Error('Bill is required for a Bill Payment — QuickBooks Desktop has no auto-apply for bill payments.'); + } + + const applyToTransactions = [{ transactionId: propsValue.billId, paymentAmount: propsValue.amount }]; + + if (paymentType === 'bill_payment_check') { + if (!propsValue.bankAccountId) { + throw new Error('Bank Account is required for a Bill Payment — Check.'); + } + const body = { + vendorId: propsValue.vendorId, + bankAccountId: propsValue.bankAccountId, + ...commonBody, + applyToTransactions, + }; + const result = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/bill-check-payments', + body, + safeToRetry: false, + }) + ); + return flattenPayment({ payment: result, paymentType }); + } + + if (!propsValue.creditCardAccountId) { + throw new Error('Credit Card Account is required for a Bill Payment — Credit Card.'); + } + const body = { + vendorId: propsValue.vendorId, + creditCardAccountId: propsValue.creditCardAccountId, + ...commonBody, + applyToTransactions, + }; + const result = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/bill-credit-card-payments', + body, + safeToRetry: false, + }) + ); + return flattenPayment({ payment: result, paymentType }); + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-customer.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-customer.ts new file mode 100644 index 000000000000..9784c6afd026 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-customer.ts @@ -0,0 +1,178 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tryCatch, spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, withStaleRevisionRetry, withRecordLockRetry, ConductorAuth } from '../common/client'; +import { ConductorApiError } from '../common/errors'; +import { buildBillingAddress, billingAddressProps, billingAddressPropertyGroup, ConductorAddress } from '../common/address'; +import { upsertCustomerActionOutputSchema } from '../output-schemas'; + +const MAX_NAME_LENGTH = 41; + +type ConductorCustomer = { + id: string; + name: string; + fullName: string; + companyName: string | null; + isActive: boolean; + email: string | null; + phone: string | null; + note: string | null; + billingAddress: ConductorAddress | null; + revisionNumber: string; + createdAt: string; + updatedAt: string; +}; + +async function lookupCustomerByName({ + auth, + name, +}: { + auth: ConductorAuth; + name: string; +}): Promise { + const { data, error } = await tryCatch(() => + conductorClient.request<{ data: ConductorCustomer[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/customers', + queryParams: { fullNames: name }, + }) + ); + if (error) { + // An exact `fullNames` filter that matches nothing comes back as an error, not an empty + // list — that specific case just means "no existing customer." Anything else is a real + // failure and shouldn't be treated as "doesn't exist yet." + if (error instanceof ConductorApiError && error.isNotFound) { + return undefined; + } + throw error; + } + return data.data[0]; +} + +function flattenCustomer(customer: ConductorCustomer) { + return { + id: customer.id, + name: customer.name, + full_name: customer.fullName, + company_name: customer.companyName, + is_active: customer.isActive, + email: customer.email, + phone: customer.phone, + note: customer.note, + billing_address_line1: customer.billingAddress?.line1 ?? null, + billing_address_city: customer.billingAddress?.city ?? null, + billing_address_state: customer.billingAddress?.state ?? null, + billing_address_postal_code: customer.billingAddress?.postalCode ?? null, + billing_address_country: customer.billingAddress?.country ?? null, + revision_number: customer.revisionNumber, + created_at: customer.createdAt, + updated_at: customer.updatedAt, + }; +} + +export const upsertCustomerAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'upsert_customer', + classification: 'WRITE', + displayName: 'Upsert Customer', + description: 'Creates a customer in QuickBooks Desktop, or updates it if one with the same name already exists.', + audience: 'both', + aiMetadata: { + description: + 'Create or update a QuickBooks Desktop customer, matched by exact name. Use this before creating an invoice or receiving a payment for a customer that may not exist yet — it looks the customer up first and updates it instead of creating a duplicate. Safe to retry: calling it again with the same name always resolves to the same customer record.', + idempotent: true, + }, + outputSchema: upsertCustomerActionOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Customer Name', + description: + `The exact name of the customer as it should appear in QuickBooks Desktop (max ${MAX_NAME_LENGTH} characters). Used to find an existing customer — if one already exists with this exact name, it is updated instead of duplicated.`, + required: true, + }), + companyName: Property.ShortText({ + displayName: 'Company Name', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + }), + note: Property.LongText({ + displayName: 'Note', + required: false, + }), + ...billingAddressProps, + }, + propertyGroups: [billingAddressPropertyGroup], + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + if (propsValue.name.length > MAX_NAME_LENGTH) { + // Caught here rather than left to Conductor: an over-length name comes back as a generic + // "internal server error" from their side, so failing fast gives the real reason instead. + throw new Error( + `Customer name must be ${MAX_NAME_LENGTH} characters or fewer (QuickBooks Desktop's limit) — "${propsValue.name}" is ${propsValue.name.length}.` + ); + } + + const billingAddress = buildBillingAddress(propsValue); + const body = { + name: propsValue.name, + ...spreadIfDefined('companyName', propsValue.companyName), + ...spreadIfDefined('email', propsValue.email), + ...spreadIfDefined('phone', propsValue.phone), + ...spreadIfDefined('note', propsValue.note), + ...spreadIfDefined('billingAddress', billingAddress), + }; + + const existingCustomer = await lookupCustomerByName({ auth, name: propsValue.name }); + + if (existingCustomer) { + const updatedCustomer = await withRecordLockRetry(() => + withStaleRevisionRetry({ + revisionNumber: existingCustomer.revisionNumber, + attempt: (revisionNumber) => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: `/quickbooks-desktop/customers/${existingCustomer.id}`, + body: { ...body, revisionNumber }, + }), + refetchRevisionNumber: async () => { + const fresh = await lookupCustomerByName({ auth, name: propsValue.name }); + if (!fresh) { + throw new Error(`Customer "${propsValue.name}" no longer exists in QuickBooks Desktop.`); + } + return fresh.revisionNumber; + }, + }) + ); + return flattenCustomer(updatedCustomer); + } + + const createdCustomer = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/customers', + body, + // This is the create branch (no id in the URL) — see client.ts's `request` doc on why + // creates opt out of retry. The update branch above keeps the default: a resent update + // either matches the same revisionNumber or gets rejected as stale, never duplicated. + safeToRetry: false, + }) + ); + return flattenCustomer(createdCustomer); + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-vendor.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-vendor.ts new file mode 100644 index 000000000000..e158fca464c2 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/actions/upsert-vendor.ts @@ -0,0 +1,173 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tryCatch, spreadIfDefined } from '@activepieces/pieces-framework'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, withStaleRevisionRetry, withRecordLockRetry, ConductorAuth } from '../common/client'; +import { ConductorApiError } from '../common/errors'; +import { buildBillingAddress, billingAddressProps, billingAddressPropertyGroup, ConductorAddress } from '../common/address'; +import { upsertVendorActionOutputSchema } from '../output-schemas'; + +const MAX_NAME_LENGTH = 41; + +type ConductorVendor = { + id: string; + name: string; + companyName: string | null; + isActive: boolean; + email: string | null; + phone: string | null; + note: string | null; + billingAddress: ConductorAddress | null; + revisionNumber: string; + createdAt: string; + updatedAt: string; +}; + +async function lookupVendorByName({ + auth, + name, +}: { + auth: ConductorAuth; + name: string; +}): Promise { + const { data, error } = await tryCatch(() => + conductorClient.request<{ data: ConductorVendor[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/vendors', + // Vendors have no parent/child hierarchy, unlike customers, so the exact-match filter is + // `names`, not `fullNames`. + queryParams: { names: name }, + }) + ); + if (error) { + // Same as customers: an exact-match filter with zero results raises an error instead of + // returning an empty list. + if (error instanceof ConductorApiError && error.isNotFound) { + return undefined; + } + throw error; + } + return data.data[0]; +} + +function flattenVendor(vendor: ConductorVendor) { + return { + id: vendor.id, + name: vendor.name, + company_name: vendor.companyName, + is_active: vendor.isActive, + email: vendor.email, + phone: vendor.phone, + note: vendor.note, + billing_address_line1: vendor.billingAddress?.line1 ?? null, + billing_address_city: vendor.billingAddress?.city ?? null, + billing_address_state: vendor.billingAddress?.state ?? null, + billing_address_postal_code: vendor.billingAddress?.postalCode ?? null, + billing_address_country: vendor.billingAddress?.country ?? null, + revision_number: vendor.revisionNumber, + created_at: vendor.createdAt, + updated_at: vendor.updatedAt, + }; +} + +export const upsertVendorAction = createAction({ + auth: quickbooksDesktopConductorAuth, + name: 'upsert_vendor', + classification: 'WRITE', + displayName: 'Upsert Vendor', + description: 'Creates a vendor in QuickBooks Desktop, or updates it if one with the same name already exists.', + audience: 'both', + aiMetadata: { + description: + 'Create or update a QuickBooks Desktop vendor, matched by exact name. Use this before creating a bill or recording a bill payment for a vendor that may not exist yet — it looks the vendor up first and updates it instead of creating a duplicate. Safe to retry: calling it again with the same name always resolves to the same vendor record.', + idempotent: true, + }, + outputSchema: upsertVendorActionOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Vendor Name', + description: + `The exact name of the vendor as it should appear in QuickBooks Desktop (max ${MAX_NAME_LENGTH} characters, unique across all vendors). Used to find an existing vendor — if one already exists with this exact name, it is updated instead of duplicated.`, + required: true, + }), + companyName: Property.ShortText({ + displayName: 'Company Name', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + }), + note: Property.LongText({ + displayName: 'Note', + required: false, + }), + ...billingAddressProps, + }, + propertyGroups: [billingAddressPropertyGroup], + async run(context) { + const { propsValue } = context; + const auth: ConductorAuth = { + secretKey: context.auth.props.secretKey, + endUserId: context.auth.props.endUserId, + }; + + if (propsValue.name.length > MAX_NAME_LENGTH) { + throw new Error( + `Vendor name must be ${MAX_NAME_LENGTH} characters or fewer (QuickBooks Desktop's limit) — "${propsValue.name}" is ${propsValue.name.length}.` + ); + } + + const billingAddress = buildBillingAddress(propsValue); + const body = { + name: propsValue.name, + ...spreadIfDefined('companyName', propsValue.companyName), + ...spreadIfDefined('email', propsValue.email), + ...spreadIfDefined('phone', propsValue.phone), + ...spreadIfDefined('note', propsValue.note), + ...spreadIfDefined('billingAddress', billingAddress), + }; + + const existingVendor = await lookupVendorByName({ auth, name: propsValue.name }); + + if (existingVendor) { + const updatedVendor = await withRecordLockRetry(() => + withStaleRevisionRetry({ + revisionNumber: existingVendor.revisionNumber, + attempt: (revisionNumber) => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: `/quickbooks-desktop/vendors/${existingVendor.id}`, + body: { ...body, revisionNumber }, + }), + refetchRevisionNumber: async () => { + const fresh = await lookupVendorByName({ auth, name: propsValue.name }); + if (!fresh) { + throw new Error(`Vendor "${propsValue.name}" no longer exists in QuickBooks Desktop.`); + } + return fresh.revisionNumber; + }, + }) + ); + return flattenVendor(updatedVendor); + } + + const createdVendor = await withRecordLockRetry(() => + conductorClient.request({ + auth, + method: HttpMethod.POST, + resourceUri: '/quickbooks-desktop/vendors', + body, + // Create branch (no id in the URL) — see the matching comment in upsert-customer.ts. + safeToRetry: false, + }) + ); + return flattenVendor(createdVendor); + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/auth.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/auth.ts new file mode 100644 index 000000000000..15bb116382f2 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/auth.ts @@ -0,0 +1,52 @@ +import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { conductorClient } from './common/client'; + +const authDescription = ` +This piece connects to QuickBooks Desktop through [Conductor](https://conductor.is) — Activepieces +cannot talk to QuickBooks Desktop directly, since it has no cloud API of its own. + +1. In your Conductor dashboard, go to **Settings → API Keys** and copy your **Secret Key**. +2. Under **End-Users**, select (or create) the end-user connected to this QuickBooks Desktop + company file and copy their **End-User ID** (starts with \`end_usr_\`). +3. The end-user must have already completed Conductor's auth flow and installed the QuickBooks + Web Connector — Conductor's dashboard shows the connection as "Connected" once QuickBooks + Desktop is reachable. If it shows "Not connected," fix that in Conductor before connecting here. +4. **Top setup mistake — read before finishing step 3:** when QuickBooks Desktop first asks to + authorize the Web Connector, **choose the option that keeps access even when QuickBooks Desktop + isn't running** (not "ask every time"). Picking the wrong option doesn't error — it just makes + every sync silently do nothing until someone is sitting at the QuickBooks Desktop machine to + approve it, which usually isn't noticed until a customer asks why nothing has synced in days. + See this piece's README for the full walkthrough. +`; + +export const quickbooksDesktopConductorAuth = PieceAuth.CustomAuth({ + displayName: 'Connection', + description: authDescription, + required: true, + props: { + secretKey: PieceAuth.SecretText({ + displayName: 'Conductor Secret Key', + description: 'From your Conductor dashboard, Settings → API Keys.', + required: true, + }), + endUserId: Property.ShortText({ + displayName: 'Conductor End-User ID', + description: 'The end-user ID for this QuickBooks Desktop company file (starts with end_usr_).', + required: true, + }), + }, + validate: async ({ auth }) => { + try { + await conductorClient.healthCheck({ + secretKey: auth.secretKey, + endUserId: auth.endUserId, + }); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Could not connect to QuickBooks Desktop through Conductor.', + }; + } + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/accounts.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/accounts.ts new file mode 100644 index 000000000000..94b487b24333 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/accounts.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { tryCatch } from '@activepieces/pieces-framework'; +import { conductorClient, ConductorAuth } from './client'; +import { ConductorApiError } from './errors'; + +type ConductorAccountLookupResult = { + id: string; + fullName: string; +}; + +/** + * Resolves an expense account's human-readable name (e.g. "Expenses:Fuel") to the opaque + * `accountId` QuickBooks Desktop bill expense lines need — same rationale as + * `resolveItemIdByName` in `items.ts`: users type a name they recognize from their Chart of + * Accounts, never an opaque id. + */ +export async function resolveAccountIdByName({ + auth, + name, +}: { + auth: ConductorAuth; + name: string; +}): Promise { + const { data, error } = await tryCatch(() => + conductorClient.request<{ data: ConductorAccountLookupResult[] }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/accounts', + queryParams: { fullNames: name }, + }) + ); + if (error) { + if (error instanceof ConductorApiError && error.isNotFound) { + throw new Error( + `Account "${name}" was not found in QuickBooks Desktop's Chart of Accounts. Check the exact name (Lists > Chart of Accounts) — sub-accounts use "Parent:Child" form, e.g. "Expenses:Fuel".` + ); + } + throw error; + } + return data.data[0].id; +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/address.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/address.ts new file mode 100644 index 000000000000..149b3b832601 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/address.ts @@ -0,0 +1,56 @@ +import { Property } from '@activepieces/pieces-framework'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; + +export type ConductorAddress = { + line1?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; +}; + +type BillingAddressPropsValue = { + billingLine1?: string; + billingCity?: string; + billingState?: string; + billingPostalCode?: string; + billingCountry?: string; +}; + +/** + * Shared "Billing Address" props + builder — identical between upsert-customer.ts and + * upsert-vendor.ts (both resources use the same QuickBooks Desktop address shape). + */ +export const billingAddressProps = { + billingLine1: Property.ShortText({ displayName: 'Address Line 1', required: false }), + billingCity: Property.ShortText({ displayName: 'City', required: false }), + billingState: Property.ShortText({ displayName: 'State', required: false }), + billingPostalCode: Property.ShortText({ displayName: 'Postal Code', required: false }), + billingCountry: Property.ShortText({ displayName: 'Country', required: false }), +}; + +export const billingAddressPropertyGroup = { + key: 'billing', + display: 'section' as const, + label: 'Billing Address', + props: ['billingLine1', 'billingCity', 'billingState', 'billingPostalCode', 'billingCountry'], +}; + +export function buildBillingAddress(propsValue: BillingAddressPropsValue): ConductorAddress | undefined { + const hasAnyField = + propsValue.billingLine1 || + propsValue.billingCity || + propsValue.billingState || + propsValue.billingPostalCode || + propsValue.billingCountry; + if (!hasAnyField) { + return undefined; + } + return { + ...spreadIfDefined('line1', propsValue.billingLine1), + ...spreadIfDefined('city', propsValue.billingCity), + ...spreadIfDefined('state', propsValue.billingState), + ...spreadIfDefined('postalCode', propsValue.billingPostalCode), + ...spreadIfDefined('country', propsValue.billingCountry), + }; +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/client.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/client.ts new file mode 100644 index 000000000000..123674e55c6b --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/client.ts @@ -0,0 +1,113 @@ +import { httpClient, HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { ConductorApiError } from './errors'; + +const CONDUCTOR_API_BASE_URL = 'https://api.conductor.is/v1'; +const TRANSIENT_HTTP_RETRIES = 2; + +export type ConductorAuth = { + secretKey: string; + endUserId: string; +}; + +export const conductorClient = { + /** + * `safeToRetry` (default `true`) controls whether a transient failure (timeout, 5xx) gets + * `httpClient`'s automatic retry. Conductor has no idempotency-key mechanism — its + * `Conductor-Request-Id` is a response-only tracking id for support, not something you can send + * to dedupe a resent request — so retrying a create blindly can duplicate a real accounting + * record if the original request actually succeeded server-side but its response was lost. + * Reads are always safe to retry (the default). Updates-by-id are also safe by default: a + * resent update either lands on the same `revisionNumber` result or gets rejected as stale by + * `withStaleRevisionRetry`, neither of which duplicates anything. Every call that **creates** a + * new record (no id in the URL) must explicitly pass `safeToRetry: false`. + */ + async request({ + auth, + method, + resourceUri, + body, + queryParams, + safeToRetry = true, + }: { + auth: ConductorAuth; + method: HttpMethod; + resourceUri: string; + body?: unknown; + queryParams?: Record; + safeToRetry?: boolean; + }): Promise { + const request: HttpRequest = { + method, + url: `${CONDUCTOR_API_BASE_URL}${resourceUri}`, + headers: { + Authorization: `Bearer ${auth.secretKey}`, + 'Conductor-End-User-Id': auth.endUserId, + }, + body, + queryParams, + retries: safeToRetry ? TRANSIENT_HTTP_RETRIES : 0, + }; + try { + const response = await httpClient.sendRequest(request); + return response.body; + } catch (error) { + throw new ConductorApiError(error); + } + }, + + async healthCheck(auth: ConductorAuth): Promise<{ status: string }> { + return conductorClient.request<{ status: string }>({ + auth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/health-check', + }); + }, +}; + +/** + * Retries an update once when QuickBooks Desktop rejects it for a stale `revisionNumber` — the + * record changed between the caller's lookup and this update. A second failure propagates as-is. + */ +export async function withStaleRevisionRetry({ + attempt, + refetchRevisionNumber, + revisionNumber, +}: { + attempt: (revisionNumber: string) => Promise; + refetchRevisionNumber: () => Promise; + revisionNumber: string; +}): Promise { + try { + return await attempt(revisionNumber); + } catch (error) { + if (error instanceof ConductorApiError && error.isStaleRevision) { + const freshRevisionNumber = await refetchRevisionNumber(); + return attempt(freshRevisionNumber); + } + throw error; + } +} + +const RECORD_LOCK_RETRY_DELAY_MS = 1500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retries a mutating call once when QuickBooks Desktop rejects it because the record is already + * being processed by another in-flight request against the same company file. No refetch needed, + * just a short wait. Wrap the outermost call (e.g. around `withStaleRevisionRetry`) so the two + * retry paths compose instead of nesting bespoke retry logic per action. + */ +export async function withRecordLockRetry(attempt: () => Promise): Promise { + try { + return await attempt(); + } catch (error) { + if (error instanceof ConductorApiError && error.isRecordLocked) { + await delay(RECORD_LOCK_RETRY_DELAY_MS); + return attempt(); + } + throw error; + } +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/dropdowns.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/dropdowns.ts new file mode 100644 index 000000000000..7cc8b9071613 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/dropdowns.ts @@ -0,0 +1,55 @@ +import { Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { conductorClient, ConductorAuth } from './client'; + +/** + * Shared "pick an existing customer/vendor by name" dropdowns — the same fetch-and-list-by-name + * logic was copy-pasted across create-invoice.ts, create-bill.ts, and record-payment.ts (4 + * call sites total). `required` and `description` differ per call site (a required top-level + * pick vs. an optional one relevant to only one mode of a multi-mode action), so these are + * factories, not shared constants. + */ +export function customerIdDropdown({ required, description }: { required: boolean; description: string }) { + return Property.Dropdown({ + displayName: 'Customer', + description, + auth: quickbooksDesktopConductorAuth, + required, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, placeholder: 'Connect your account first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ data: { id: string; fullName: string }[] }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/customers', + }); + return { disabled: false, options: response.data.map((customer) => ({ label: customer.fullName, value: customer.id })) }; + }, + }); +} + +export function vendorIdDropdown({ required, description }: { required: boolean; description: string }) { + return Property.Dropdown({ + displayName: 'Vendor', + description, + auth: quickbooksDesktopConductorAuth, + required, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, placeholder: 'Connect your account first', options: [] }; + } + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const response = await conductorClient.request<{ data: { id: string; name: string }[] }>({ + auth: conductorAuth, + method: HttpMethod.GET, + resourceUri: '/quickbooks-desktop/vendors', + }); + return { disabled: false, options: response.data.map((vendor) => ({ label: vendor.name, value: vendor.id })) }; + }, + }); +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/errors.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/errors.ts new file mode 100644 index 000000000000..56e921560311 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/errors.ts @@ -0,0 +1,102 @@ +const TRANSIENT_CONDUCTOR_ERROR_CODES = new Set([ + 'QBD_CONNECTION_ERROR', + 'QBD_REQUEST_TIMEOUT', +]); + +// QuickBooks Desktop raises an error, not an empty result, when an exact-match filter (e.g. +// `fullNames`) matches zero records. Conductor passes it through as a QBD_REQUEST_ERROR. +const NOT_FOUND_MESSAGE_PATTERN = /could not be found in QuickBooks/i; + +// QuickBooks Desktop's optimistic-concurrency check: an update whose `revisionNumber` no longer +// matches the record's current one fails with this message. Recoverable by re-fetching and +// retrying once — see `withStaleRevisionRetry` in `client.ts`. +const STALE_REVISION_MESSAGE_PATTERN = /revision number \(edit sequence\).*is out-of-date/i; + +// QuickBooks Desktop itself processes one request at a time per company file, so two requests +// touching the same record close together get this rejection on the loser. Real at scale — +// concurrent flow runs, or someone editing the same record by hand — not an edge case. No new +// data needed to recover, just a short wait and retry — see `withRecordLockRetry` in `client.ts`. +const RECORD_LOCKED_MESSAGE_PATTERN = /already in use/i; + +type ConductorErrorBody = { + error?: { + message?: string; + userFacingMessage?: string; + type?: string; + code?: string; + httpStatusCode?: number; + }; +}; + +function safeJsonParse(value: string): ConductorErrorBody | undefined { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function parseConductorError(error: unknown): { + message: string; + code?: string; + type?: string; + httpStatusCode?: number; +} { + const responseBody = (error as { response?: { body?: ConductorErrorBody | string } })?.response?.body; + const parsedBody = typeof responseBody === 'string' ? safeJsonParse(responseBody) : responseBody; + const conductorError = parsedBody?.error; + if (conductorError) { + return { + // userFacingMessage is accurate and worth surfacing verbatim for real QuickBooks errors — + // except INVALID_REQUEST_ERROR (Conductor's own request-shape validation, before it ever + // reaches QuickBooks), which ships a useless generic userFacingMessage while `message` + // already has the real, specific reason. Prefer `message` for that one type only. + message: + conductorError.type === 'INVALID_REQUEST_ERROR' + ? conductorError.message ?? conductorError.userFacingMessage ?? 'Unknown Conductor error' + : conductorError.userFacingMessage ?? conductorError.message ?? 'Unknown Conductor error', + code: conductorError.code, + type: conductorError.type, + httpStatusCode: conductorError.httpStatusCode, + }; + } + return { message: error instanceof Error ? error.message : 'Unknown Conductor error' }; +} + +export class ConductorApiError extends Error { + readonly code?: string; + readonly type?: string; + readonly httpStatusCode?: number; + readonly isTransient: boolean; + /** + * True when this error is QuickBooks Desktop's "no record matched an exact-name filter" + * signal disguised as a request error, rather than a genuine failure. Callers doing a + * lookup-then-upsert should treat this as "no existing record," not rethrow it. + */ + readonly isNotFound: boolean; + /** + * True when an update was rejected because its `revisionNumber` is stale — the record changed + * since the caller last fetched it. Recoverable by re-fetching and retrying once; see + * `withStaleRevisionRetry` in `client.ts`. + */ + readonly isStaleRevision: boolean; + /** + * True when QuickBooks Desktop itself rejected the request because the record is currently + * being processed by another in-flight request against the same company file. Recoverable by + * waiting briefly and retrying the identical request; see `withRecordLockRetry` in `client.ts`. + */ + readonly isRecordLocked: boolean; + + constructor(rawError: unknown) { + const { message, code, type, httpStatusCode } = parseConductorError(rawError); + super(message); + this.name = 'ConductorApiError'; + this.code = code; + this.type = type; + this.httpStatusCode = httpStatusCode; + this.isTransient = code !== undefined && TRANSIENT_CONDUCTOR_ERROR_CODES.has(code); + this.isNotFound = code === 'QBD_REQUEST_ERROR' && NOT_FOUND_MESSAGE_PATTERN.test(message); + this.isStaleRevision = code === 'QBD_REQUEST_ERROR' && STALE_REVISION_MESSAGE_PATTERN.test(message); + this.isRecordLocked = code === 'QBD_REQUEST_ERROR' && RECORD_LOCKED_MESSAGE_PATTERN.test(message); + } +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/invoices.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/invoices.ts new file mode 100644 index 000000000000..d8ca9573ddda --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/invoices.ts @@ -0,0 +1,42 @@ +export type ConductorInvoice = { + id: string; + transactionDate: string; + dueDate: string | null; + refNumber: string | null; + memo: string | null; + customer: { id: string; fullName: string } | null; + // There's no `total` field — it's derived below from subtotal + salesTaxTotal. + subtotal: string; + salesTaxTotal: string | null; + balanceRemaining: string; + isPaid: boolean; + lines: unknown[]; + revisionNumber: string; + createdAt: string; + updatedAt: string; +}; + +function addDecimalStrings(a: string, b: string): string { + return (Number(a) + Number(b)).toFixed(2); +} + +export function flattenInvoice(invoice: ConductorInvoice) { + return { + id: invoice.id, + transaction_date: invoice.transactionDate, + due_date: invoice.dueDate, + ref_number: invoice.refNumber, + memo: invoice.memo, + customer_id: invoice.customer?.id ?? null, + customer_name: invoice.customer?.fullName ?? null, + subtotal_amount: invoice.subtotal, + sales_tax_amount: invoice.salesTaxTotal, + total_amount: addDecimalStrings(invoice.subtotal, invoice.salesTaxTotal ?? '0'), + balance_remaining: invoice.balanceRemaining, + is_paid: invoice.isPaid, + line_count: invoice.lines?.length ?? 0, + revision_number: invoice.revisionNumber, + created_at: invoice.createdAt, + updated_at: invoice.updatedAt, + }; +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/items.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/items.ts new file mode 100644 index 000000000000..9ca177093ccc --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/items.ts @@ -0,0 +1,68 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { tryCatch } from '@activepieces/pieces-framework'; +import { conductorClient, ConductorAuth } from './client'; +import { ConductorApiError } from './errors'; + +type ConductorItemLookupResult = { + id: string; + fullName: string; +}; + +async function lookupItemByFullName({ + auth, + resourceUri, + name, +}: { + auth: ConductorAuth; + resourceUri: string; + name: string; +}): Promise { + const { data, error } = await tryCatch(() => + conductorClient.request<{ data: ConductorItemLookupResult[] }>({ + auth, + method: HttpMethod.GET, + resourceUri, + queryParams: { fullNames: name }, + }) + ); + if (error) { + if (error instanceof ConductorApiError && error.isNotFound) { + return undefined; + } + throw error; + } + return data.data[0]; +} + +/** + * Resolves a line-item's human-readable name to the opaque `itemId` QuickBooks Desktop line + * items actually need. `Property.Dropdown` (dynamic, single-select) is not an allowed type + * inside `Property.Array`'s `properties` — confirmed against + * `packages/pieces/framework/src/lib/property/input/array-property.ts`'s `ArraySubProps` schema, + * which is also why the sibling `quickbooks` (QBO) piece's own line items use a raw ShortText for + * `itemId` rather than a dropdown. Resolving by name server-side (same lookup-by-name shape as + * `upsert-customer.ts`/`upsert-vendor.ts`) avoids asking users to type an opaque id. + * + * Checks Service Items first, then Non-Inventory Items — the two item types a service business + * actually invoices against. Inventory, discount, other-charge, and item-group items are out of + * scope for v1 (use `custom_api_call` for those). + */ +export async function resolveItemIdByName({ + auth, + name, +}: { + auth: ConductorAuth; + name: string; +}): Promise { + const serviceItem = await lookupItemByFullName({ auth, resourceUri: '/quickbooks-desktop/service-items', name }); + if (serviceItem) { + return serviceItem.id; + } + const nonInventoryItem = await lookupItemByFullName({ auth, resourceUri: '/quickbooks-desktop/non-inventory-items', name }); + if (nonInventoryItem) { + return nonInventoryItem.id; + } + throw new Error( + `Item "${name}" was not found among Service Items or Non-Inventory Items in QuickBooks Desktop. Check the exact name in QuickBooks Desktop's Item List (Lists > Item List).` + ); +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/payments.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/payments.ts new file mode 100644 index 000000000000..4e01c8f027a4 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/payments.ts @@ -0,0 +1,39 @@ +export type PaymentType = 'customer_payment' | 'bill_payment_check' | 'bill_payment_credit_card'; + +export type ConductorPaymentResult = { + id: string; + transactionDate: string; + refNumber: string | null; + memo: string | null; + // receive-payments uses `totalAmount`, the two bill-payment endpoints use plain `amount` — + // neither uses `amountDue` (that's the bill's own field, not the payment against it). + totalAmount: string | null; + amount: string | null; + customer: { id: string; fullName: string } | null; + vendor: { id: string; fullName: string } | null; + revisionNumber: string; + createdAt: string; + updatedAt: string; +}; + +export function isPaymentType(value: string): value is PaymentType { + return value === 'customer_payment' || value === 'bill_payment_check' || value === 'bill_payment_credit_card'; +} + +export function flattenPayment({ payment, paymentType }: { payment: ConductorPaymentResult; paymentType: PaymentType }) { + return { + id: payment.id, + payment_type: paymentType, + transaction_date: payment.transactionDate, + ref_number: payment.refNumber, + memo: payment.memo, + amount: payment.totalAmount ?? payment.amount, + customer_id: payment.customer?.id ?? null, + customer_name: payment.customer?.fullName ?? null, + vendor_id: payment.vendor?.id ?? null, + vendor_name: payment.vendor?.fullName ?? null, + revision_number: payment.revisionNumber, + created_at: payment.createdAt, + updated_at: payment.updatedAt, + }; +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/polling.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/polling.ts new file mode 100644 index 000000000000..962b34d501a6 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/common/polling.ts @@ -0,0 +1,68 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { conductorClient, ConductorAuth } from './client'; + +type ConductorListResponse = { + data: T[]; + nextCursor: string | null; + hasMore: boolean; +}; + +// Conductor's `updatedAfter` filter is inclusive, so we deliberately re-fetch the record that set +// the last checkpoint — pollingHelper's own strict `epochMilliSeconds > lastFetchEpochMS` filters +// it back out. That pairing is what keeps TIMEBASED safe at the boundary; an exclusive filter here +// could drop a record whose write lands just after the previous poll already ran. +// +// One gap that doesn't fix: two different records sharing the same second, where one only becomes +// visible in a later poll, collide on that epoch and the late one gets silently dropped. That's a +// limitation of pollingHelper's TIMEBASED strategy itself, not worth a bespoke cursor here — noted +// in both triggers' descriptions instead. +// +// Separate issue: an `updatedAfter` near the Unix epoch silently returns nothing even when +// matching data exists. `pollingHelper.test()` always polls from epoch zero, so every trigger's +// first test in the builder would otherwise show "no results" with nothing pointing at why. +// Clamping below never affects real polling, since a legitimate checkpoint is never this old. +const MIN_SAFE_UPDATED_AFTER_MS = Date.parse('1980-01-01T00:00:00.000Z'); + +export async function fetchAllUpdatedSince({ + auth, + resourceUri, + updatedAfterEpochMS, +}: { + auth: ConductorAuth; + resourceUri: string; + updatedAfterEpochMS: number; +}): Promise { + const safeEpochMS = Math.max(updatedAfterEpochMS, MIN_SAFE_UPDATED_AFTER_MS); + const updatedAfter = new Date(safeEpochMS).toISOString(); + return fetchPage({ auth, resourceUri, updatedAfter, cursor: undefined, itemsSoFar: [] }); +} + +async function fetchPage({ + auth, + resourceUri, + updatedAfter, + cursor, + itemsSoFar, +}: { + auth: ConductorAuth; + resourceUri: string; + updatedAfter: string; + cursor: string | undefined; + itemsSoFar: T[]; +}): Promise { + const response = await conductorClient.request>({ + auth, + method: HttpMethod.GET, + resourceUri, + queryParams: { + updatedAfter, + limit: '150', + ...(cursor ? { cursor } : {}), + }, + }); + const items = [...itemsSoFar, ...response.data]; + if (!response.hasMore || !response.nextCursor) { + return items; + } + return fetchPage({ auth, resourceUri, updatedAfter, cursor: response.nextCursor, itemsSoFar: items }); +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/output-schemas.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/output-schemas.ts new file mode 100644 index 000000000000..90b3b5b33a27 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/output-schemas.ts @@ -0,0 +1,150 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const upsertCustomerActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Customer ID' }, + { key: 'name', label: 'Name' }, + { key: 'full_name', label: 'Full Name' }, + { key: 'company_name', label: 'Company Name' }, + { key: 'is_active', label: 'Is Active', format: 'boolean' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'phone', label: 'Phone' }, + { key: 'note', label: 'Note' }, + { key: 'billing_address_line1', label: 'Billing Address Line 1' }, + { key: 'billing_address_city', label: 'Billing Address City' }, + { key: 'billing_address_state', label: 'Billing Address State' }, + { key: 'billing_address_postal_code', label: 'Billing Address Postal Code' }, + { key: 'billing_address_country', label: 'Billing Address Country' }, + { key: 'revision_number', label: 'Revision Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], +}; + +export const createInvoiceActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Invoice ID' }, + { key: 'transaction_date', label: 'Invoice Date', format: 'date' }, + { key: 'due_date', label: 'Due Date', format: 'date' }, + { key: 'ref_number', label: 'Invoice Number' }, + { key: 'memo', label: 'Memo' }, + { key: 'customer_id', label: 'Customer ID' }, + { key: 'customer_name', label: 'Customer Name' }, + { key: 'subtotal_amount', label: 'Subtotal', format: 'currency', currency: 'USD' }, + { key: 'sales_tax_amount', label: 'Sales Tax', format: 'currency', currency: 'USD' }, + { key: 'total_amount', label: 'Total', format: 'currency', currency: 'USD' }, + { key: 'balance_remaining', label: 'Balance Remaining', format: 'currency', currency: 'USD' }, + { key: 'is_paid', label: 'Is Paid', format: 'boolean' }, + { key: 'line_count', label: 'Line Item Count', format: 'number' }, + { key: 'revision_number', label: 'Revision Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], +}; + +export const createBillActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Bill ID' }, + { key: 'transaction_date', label: 'Bill Date', format: 'date' }, + { key: 'due_date', label: 'Due Date', format: 'date' }, + { key: 'ref_number', label: 'Bill Number' }, + { key: 'memo', label: 'Memo' }, + { key: 'vendor_id', label: 'Vendor ID' }, + { key: 'vendor_name', label: 'Vendor Name' }, + { key: 'amount_due', label: 'Amount Due', format: 'currency', currency: 'USD' }, + { key: 'balance_remaining', label: 'Balance Remaining', format: 'currency', currency: 'USD' }, + { key: 'is_paid', label: 'Is Paid', format: 'boolean' }, + { key: 'line_count', label: 'Expense Line Count', format: 'number' }, + { key: 'revision_number', label: 'Revision Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], +}; + +export const recordPaymentActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Payment ID' }, + { key: 'payment_type', label: 'Payment Type' }, + { key: 'transaction_date', label: 'Payment Date', format: 'date' }, + { key: 'ref_number', label: 'Reference / Check Number' }, + { key: 'memo', label: 'Memo' }, + { key: 'amount', label: 'Amount', format: 'currency', currency: 'USD' }, + { key: 'customer_id', label: 'Customer ID' }, + { key: 'customer_name', label: 'Customer Name' }, + { key: 'vendor_id', label: 'Vendor ID' }, + { key: 'vendor_name', label: 'Vendor Name' }, + { key: 'revision_number', label: 'Revision Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], +}; + +export const queryTransactionsActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'transactions', + label: 'Transactions', + labelKey: 'ref_number', + listItems: [ + { key: 'transaction_type', label: 'Type' }, + { key: 'transaction_id', label: 'Transaction ID' }, + { key: 'transaction_date', label: 'Date', format: 'date' }, + { key: 'ref_number', label: 'Reference Number' }, + { key: 'amount', label: 'Amount', format: 'currency', currency: 'USD' }, + { key: 'memo', label: 'Memo' }, + { key: 'entity_id', label: 'Entity ID' }, + { key: 'entity_name', label: 'Entity Name' }, + { key: 'account_id', label: 'Account ID' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], + }, + { key: 'count', label: 'Result Count', format: 'number' }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + ], +}; + +export const newOrUpdatedInvoiceTriggerOutputSchema: OutputSchema = createInvoiceActionOutputSchema; + +export const newPaymentTriggerOutputSchema: OutputSchema = recordPaymentActionOutputSchema; + +export const upsertVendorActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Vendor ID' }, + { key: 'name', label: 'Name' }, + { key: 'company_name', label: 'Company Name' }, + { key: 'is_active', label: 'Is Active', format: 'boolean' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'phone', label: 'Phone' }, + { key: 'note', label: 'Note' }, + { key: 'billing_address_line1', label: 'Billing Address Line 1' }, + { key: 'billing_address_city', label: 'Billing Address City' }, + { key: 'billing_address_state', label: 'Billing Address State' }, + { key: 'billing_address_postal_code', label: 'Billing Address Postal Code' }, + { key: 'billing_address_country', label: 'Billing Address Country' }, + { key: 'revision_number', label: 'Revision Number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + ], +}; + +export const listItemsActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Items', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Item ID' }, + { key: 'name', label: 'Name' }, + { key: 'full_name', label: 'Full Name' }, + { key: 'item_type', label: 'Item Type' }, + { key: 'is_active', label: 'Is Active', format: 'boolean' }, + ], + }, + { key: 'count', label: 'Result Count', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + ], +}; diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-or-updated-invoice.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-or-updated-invoice.ts new file mode 100644 index 000000000000..dd889e0968bb --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-or-updated-invoice.ts @@ -0,0 +1,68 @@ +import { createTrigger, TriggerStrategy, AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { DedupeStrategy, Polling, pollingHelper } from '@activepieces/pieces-common'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { ConductorAuth } from '../common/client'; +import { fetchAllUpdatedSince } from '../common/polling'; +import { ConductorInvoice, flattenInvoice } from '../common/invoices'; +import { newOrUpdatedInvoiceTriggerOutputSchema } from '../output-schemas'; + +const polling: Polling, Record> = { + strategy: DedupeStrategy.TIMEBASED, + items: async ({ auth, lastFetchEpochMS }) => { + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + const invoices = await fetchAllUpdatedSince({ + auth: conductorAuth, + resourceUri: '/quickbooks-desktop/invoices', + updatedAfterEpochMS: lastFetchEpochMS, + }); + return invoices.map((invoice) => ({ + epochMilliSeconds: new Date(invoice.updatedAt).getTime(), + data: flattenInvoice(invoice), + })); + }, +}; + +export const newOrUpdatedInvoiceTrigger = createTrigger({ + auth: quickbooksDesktopConductorAuth, + name: 'new_or_updated_invoice', + displayName: 'New or Updated Invoice', + description: 'Fires when an invoice is created or updated (e.g. line items, balance, or payment status changed) in QuickBooks Desktop. One event per invoice change, not create-only.', + aiMetadata: { + description: 'Fires when an invoice is created or changed in QuickBooks Desktop, emitting the current invoice record. Fires on every update to a matching invoice, not just its creation — a flow reacting only to brand-new invoices should check whether created_at and updated_at are close together. Polls every ~5 minutes; if the QuickBooks Desktop machine is off or asleep at poll time, the poll fails visibly (Conductor returns a connection error) rather than silently returning zero results.', + }, + props: {}, + type: TriggerStrategy.POLLING, + outputSchema: newOrUpdatedInvoiceTriggerOutputSchema, + async onEnable(context) { + await pollingHelper.onEnable(polling, context); + }, + async onDisable(context) { + await pollingHelper.onDisable(polling, context); + }, + async test(context) { + return await pollingHelper.test(polling, context); + }, + async run(context) { + return await pollingHelper.poll(polling, context); + }, + sampleData: { + id: '2FC27-1797340210', + transaction_date: '2026-08-20', + due_date: '2026-08-20', + ref_number: 'SUITE1', + memo: 'full suite test', + customer_id: '800000EA-1797339907', + customer_name: 'Suite Test Customer', + subtotal_amount: '270.00', + sales_tax_amount: '0.00', + total_amount: '270.00', + // A partial payment after creation, so this sample shows the "or updated" half too, not + // just a fresh invoice. + balance_remaining: '120.00', + is_paid: false, + line_count: 1, + revision_number: '1797340229', + created_at: '2026-12-15T16:10:10+03:00', + updated_at: '2026-12-15T16:10:29+03:00', + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-payment.ts b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-payment.ts new file mode 100644 index 000000000000..225eb7790f46 --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/src/lib/triggers/new-payment.ts @@ -0,0 +1,69 @@ +import { createTrigger, TriggerStrategy, AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { DedupeStrategy, Polling, pollingHelper } from '@activepieces/pieces-common'; +import { quickbooksDesktopConductorAuth } from '../auth'; +import { ConductorAuth } from '../common/client'; +import { fetchAllUpdatedSince } from '../common/polling'; +import { ConductorPaymentResult, flattenPayment } from '../common/payments'; +import { newPaymentTriggerOutputSchema } from '../output-schemas'; + +const polling: Polling, Record> = { + strategy: DedupeStrategy.TIMEBASED, + items: async ({ auth, lastFetchEpochMS }) => { + const conductorAuth: ConductorAuth = { secretKey: auth.props.secretKey, endUserId: auth.props.endUserId }; + // This trigger is create-only (the invoice one is create-or-update). Conductor only offers + // `updatedAfter`, not `createdAfter`, so we fetch broad and dedupe narrow: query by + // `updatedAfter`, then key each item's epoch on its own `createdAt`. Since `updatedAt` is + // always >= `createdAt`, that query can never miss a genuinely new payment — it just also pulls + // in old payments that were merely edited, which pollingHelper then filters out by their old + // `createdAt`. A bit of wasted fetching, but there's no cheaper way to ask Conductor directly. + const payments = await fetchAllUpdatedSince({ + auth: conductorAuth, + resourceUri: '/quickbooks-desktop/receive-payments', + updatedAfterEpochMS: lastFetchEpochMS, + }); + return payments.map((payment) => ({ + epochMilliSeconds: new Date(payment.createdAt).getTime(), + data: flattenPayment({ payment, paymentType: 'customer_payment' }), + })); + }, +}; + +export const newPaymentTrigger = createTrigger({ + auth: quickbooksDesktopConductorAuth, + name: 'new_payment', + displayName: 'New Payment', + description: 'Fires once when a new customer payment is recorded (Accounts Receivable) in QuickBooks Desktop — a "Receive Payment" transaction, e.g. from the Record Payment action\'s Customer Payment mode. Create-only: editing an existing payment does not re-fire it. Vendor bill payments (Accounts Payable) do not fire this trigger.', + aiMetadata: { + description: 'Fires when a new customer payment is recorded in QuickBooks Desktop (Accounts Receivable — "Receive Payment"), emitting the payment record. Create-only — later edits to that same payment do not fire it again. Scoped to customer payments only — vendor bill payments (Accounts Payable) are not covered. Polls every ~5 minutes; if the QuickBooks Desktop machine is off or asleep at poll time, the poll fails visibly (Conductor returns a connection error) rather than silently returning zero results.', + }, + props: {}, + type: TriggerStrategy.POLLING, + outputSchema: newPaymentTriggerOutputSchema, + async onEnable(context) { + await pollingHelper.onEnable(polling, context); + }, + async onDisable(context) { + await pollingHelper.onDisable(polling, context); + }, + async test(context) { + return await pollingHelper.test(polling, context); + }, + async run(context) { + return await pollingHelper.poll(polling, context); + }, + sampleData: { + id: '2FC2E-1797340229', + payment_type: 'customer_payment', + transaction_date: '2026-08-20', + ref_number: 'SUITEPMT1', + memo: 'full suite test', + amount: '270.00', + customer_id: '800000EA-1797339907', + customer_name: 'Suite Test Customer', + vendor_id: null, + vendor_name: null, + revision_number: '1797340229', + created_at: '2026-12-15T16:10:29+03:00', + updated_at: '2026-12-15T16:10:29+03:00', + }, +}); diff --git a/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.json b/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.json new file mode 100644 index 000000000000..71bc5814f5de --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.lib.json b/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.lib.json new file mode 100644 index 000000000000..a648eb45d7fa --- /dev/null +++ b/packages/pieces/community/quickbooks-desktop-conductor/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 983a05ecb3f8..5add412780b7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1070,6 +1070,9 @@ "@activepieces/piece-quickbooks": [ "packages/pieces/community/quickbooks/src/index.ts" ], + "@activepieces/piece-quickbooks-desktop-conductor": [ + "packages/pieces/community/quickbooks-desktop-conductor/src/index.ts" + ], "@activepieces/piece-quickbooks-sandbox": [ "packages/pieces/community/quickbooks-sandbox/src/index.ts" ],