* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
import type { WooCredentials } from './api-client'
|
|
import { decryptCredential } from './credentials'
|
|
import type { WooCommerceConnection } from '../types'
|
|
|
|
/**
|
|
* WooCommerce "Auth Endpoint" handshake helpers.
|
|
*
|
|
* The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they
|
|
* approve, WooCommerce POSTs the generated consumer key/secret server-to-
|
|
* server to our callback_url and redirects the browser to return_url. Our
|
|
* oauth_state UUID rides in the handshake's user_id parameter and comes back
|
|
* in both places, tying callback and return to the pending connection row.
|
|
*
|
|
* There is no signature on the callback POST, so possession of the
|
|
* single-use state is the CSRF defense, and authenticity is proven by
|
|
* probing the STORED store_url with the received keys before activation: a
|
|
* forged POST would need working read credentials for the exact store the
|
|
* user asked to connect.
|
|
*/
|
|
|
|
const APP_NAME = 'Accounted'
|
|
|
|
export function buildAuthorizeUrl(storeUrl: string, state: string): string {
|
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
|
|
if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured')
|
|
const params = new URLSearchParams({
|
|
app_name: APP_NAME,
|
|
// Read-only: the feed never writes to the store.
|
|
scope: 'read',
|
|
user_id: state,
|
|
return_url: `${baseUrl}/api/extensions/woocommerce/return`,
|
|
callback_url: `${baseUrl}/api/extensions/woocommerce/callback`,
|
|
})
|
|
return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}`
|
|
}
|
|
|
|
/** Decrypted API credentials for an active connection. */
|
|
export function credentialsOf(
|
|
connection: Pick<
|
|
WooCommerceConnection,
|
|
'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted'
|
|
>,
|
|
): WooCredentials {
|
|
if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) {
|
|
throw new Error('Connection has no stored credentials')
|
|
}
|
|
return {
|
|
storeUrl: connection.store_url,
|
|
consumerKey: decryptCredential(connection.consumer_key_encrypted),
|
|
consumerSecret: decryptCredential(connection.consumer_secret_encrypted),
|
|
}
|
|
}
|