* 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>
45 lines
1.7 KiB
TypeScript
45 lines
1.7 KiB
TypeScript
import crypto from 'crypto'
|
|
|
|
/**
|
|
* At-rest encryption for WooCommerce consumer key/secret.
|
|
*
|
|
* AES-256-GCM with a dedicated env key, mirroring the Skatteverket token
|
|
* store (extensions/general/skatteverket/lib/token-store.ts): 12-byte IV,
|
|
* 16-byte auth tag, layout iv|tag|ciphertext, base64url encoded. The key is
|
|
* deployment-wide (not per-tenant); what makes rows useless off-server is
|
|
* that WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY never leaves the environment.
|
|
*/
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
|
|
/** Whether the integration is configured on this deployment. */
|
|
export function isWooCommerceConfigured(): boolean {
|
|
return Boolean(process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY)
|
|
}
|
|
|
|
function getEncryptionKey(): Buffer {
|
|
const key = process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY
|
|
if (!key) throw new Error('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is required')
|
|
return crypto.createHash('sha256').update(key).digest()
|
|
}
|
|
|
|
export function encryptCredential(plaintext: string): string {
|
|
const key = getEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
|
}
|
|
|
|
export function decryptCredential(ciphertext: string): string {
|
|
const key = getEncryptionKey()
|
|
const combined = Buffer.from(ciphertext, 'base64url')
|
|
const iv = combined.subarray(0, 12)
|
|
const tag = combined.subarray(12, 28)
|
|
const encrypted = combined.subarray(28)
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
|
}
|