Repository navigation
feat(infra): AWS shop module (not applied) and WooCommerce gateway #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| name: woocommerce | ||
| on: | ||
| pull_request: | ||
| paths: ['integrations/woocommerce/**'] | ||
| push: | ||
| branches: [main] | ||
| paths: ['integrations/woocommerce/**'] | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| php: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 | ||
| with: { php-version: '8.2' } | ||
| - run: find integrations/woocommerce -name '*.php' -print0 | xargs -0 -n1 php -l | ||
| - run: php integrations/woocommerce/tests/run.php |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,7 @@ bin/ | |
| *.db-shm | ||
| .agents/plans/ | ||
| coverage.out | ||
|
|
||
| # Terraform local state and providers | ||
| **/.terraform/ | ||
| *.tfstate* | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Per-shop managed hosting in ap-south-1. Plain Terraform, no apply from this repository. | ||
| terraform { | ||
| required_version = ">= 1.6" | ||
| required_providers { | ||
| aws = { source = "hashicorp/aws", version = "~> 5.0" } | ||
| } | ||
| } | ||
|
|
||
| provider "aws" { | ||
| region = "ap-south-1" | ||
| default_tags { | ||
| tags = { project = "kaasu", managed_by = "terraform" } | ||
| } | ||
| } | ||
|
|
||
| module "shop" { | ||
| source = "./modules/shop" | ||
| shop_id = var.shop_id | ||
| kms_alias = var.kms_alias | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| variable "shop_id" { type = string } | ||
| variable "kms_alias" { type = string } | ||
|
|
||
| data "aws_kms_alias" "shops" { | ||
| name = var.kms_alias | ||
| } | ||
|
|
||
| # The shop's source of truth: encrypted with the customer-managed key, versioned, no public access. | ||
| resource "aws_s3_bucket" "data" { | ||
| bucket = "kaasu-${var.shop_id}-data" | ||
| } | ||
|
|
||
| resource "aws_s3_bucket_versioning" "data" { | ||
| bucket = aws_s3_bucket.data.id | ||
| versioning_configuration { | ||
| status = "Enabled" | ||
| } | ||
| } | ||
|
|
||
| resource "aws_s3_bucket_server_side_encryption_configuration" "data" { | ||
| bucket = aws_s3_bucket.data.id | ||
| rule { | ||
| apply_server_side_encryption_by_default { | ||
| sse_algorithm = "aws:kms" | ||
| kms_master_key_id = data.aws_kms_alias.shops.target_key_arn | ||
| } | ||
| } | ||
| } | ||
|
|
||
| resource "aws_s3_bucket_public_access_block" "data" { | ||
| bucket = aws_s3_bucket.data.id | ||
| block_public_acls = true | ||
| block_public_policy = true | ||
| ignore_public_acls = true | ||
| restrict_public_buckets = true | ||
| } | ||
|
|
||
| resource "aws_cloudwatch_log_group" "task" { | ||
| name = "/kaasu/${var.shop_id}" | ||
| retention_in_days = 90 | ||
| } | ||
|
|
||
| output "bucket" { value = aws_s3_bucket.data.bucket } | ||
| output "log_group" { value = aws_cloudwatch_log_group.task.name } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| output "bucket" { | ||
| value = module.shop.bucket | ||
| description = "Source of truth for the shop's encrypted data; restored on task start." | ||
| } | ||
|
|
||
| output "log_group" { | ||
| value = module.shop.log_group | ||
| description = "CloudWatch log group for the shop's task." | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| variable "shop_id" { | ||
| type = string | ||
| description = "Lower-case id, 3 to 32 characters of a-z, 0-9 and hyphen." | ||
| validation { | ||
| condition = can(regex("^[a-z0-9-]{3,32}$", var.shop_id)) | ||
| error_message = "shop_id must be 3 to 32 lower-case letters, digits or hyphens." | ||
| } | ||
| } | ||
|
|
||
| variable "kms_alias" { | ||
| type = string | ||
| description = "Alias of the customer-managed KMS key that encrypts the shop's bucket." | ||
| default = "alias/kaasu-shops" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Publishing checklist (human steps) | ||
|
|
||
| Not published. Before submitting to the WordPress plugin directory: | ||
|
|
||
| - [ ] Test on a current WordPress and WooCommerce with HPOS on and off. | ||
| - [ ] Replace this checklist's placeholder screenshots in the readme. | ||
| - [ ] Confirm the license header matches the repository (AGPL-3.0-or-later). | ||
| - [ ] Submit through plugins.trac.wordpress.org; the directory review is a human decision. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | Correct the initial plugin submission step.
🧰 Tools
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| <?php | ||
| if (!defined('ABSPATH')) { | ||
| exit; | ||
| } | ||
|
|
||
| /** | ||
| * Converts a WooCommerce order total into integer paise without floats. | ||
| */ | ||
| final class Kaasu_Amount { | ||
| /** | ||
| * @param string $total e.g. "499.07", "1200.00", "0.29" (WooCommerce decimal string) | ||
| * @return int paise, or throws on anything that is not a plain decimal | ||
| */ | ||
| public static function to_paise(string $total): int { | ||
| $total = trim($total); | ||
| if (!preg_match('/^\d+(\.\d{1,2})?$/', $total)) { | ||
| throw new InvalidArgumentException('amount is not a plain decimal'); | ||
| } | ||
| [$rupees, $frac] = array_pad(explode('.', $total, 2), 2, ''); | ||
| $frac = str_pad($frac, 2, '0'); | ||
| $paise = ((int) $rupees) * 100 + (int) $frac; | ||
| if ($paise <= 0) { | ||
| throw new InvalidArgumentException('amount must be positive'); | ||
| } | ||
| return $paise; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| <?php | ||
| if (!defined('ABSPATH')) { | ||
| exit; | ||
| } | ||
|
|
||
| /** | ||
| * The payment method. Creates a Kaasu order, sends the shopper to the pay page, and marks the WooCommerce | ||
| * order paid only when a verified "order.confirmed" webhook arrives. | ||
| */ | ||
| final class Kaasu_Gateway extends WC_Payment_Gateway { | ||
| public function __construct() { | ||
| $this->id = 'kaasu'; | ||
| $this->method_title = 'Kaasu'; | ||
| $this->method_description = 'UPI payments confirmed by your self-hosted Kaasu server.'; | ||
| $this->has_fields = false; | ||
| $this->supports = ['products']; | ||
| $this->init_form_fields(); | ||
| $this->init_settings(); | ||
| $this->title = $this->get_option('title', 'UPI (Kaasu)'); | ||
| $this->enabled = $this->get_option('enabled', 'no'); | ||
| add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']); | ||
| add_action('woocommerce_api_kaasu', [$this, 'handle_webhook']); | ||
| } | ||
|
|
||
| public function init_form_fields(): void { | ||
| $this->form_fields = [ | ||
| 'enabled' => ['title' => 'Enable', 'type' => 'checkbox', 'default' => 'no'], | ||
| 'title' => ['title' => 'Title', 'type' => 'text', 'default' => 'UPI (Kaasu)'], | ||
| 'kaasu_url' => ['title' => 'Kaasu URL', 'type' => 'text', 'description' => 'https://pay.example.com'], | ||
| 'api_key' => ['title' => 'API key', 'type' => 'password'], | ||
| 'webhook_secret' => ['title' => 'Webhook secret', 'type' => 'password'], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | Broken Authentication Reachability: External Require a nonempty webhook secret before enabling payments. If a merchant enables Kaasu without setting
|
||
| ]; | ||
| } | ||
|
|
||
| public function process_payment($order_id): array { | ||
| $order = wc_get_order($order_id); | ||
| $amount = Kaasu_Amount::to_paise((string) $order->get_total()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | Reject orders in currencies other than INR. If an order uses another currency, this call interprets its numeric total as rupees and sends that value as
|
||
| $response = wp_remote_post(rtrim($this->get_option('kaasu_url'), '/') . '/v1/orders', [ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | Sensitive Data Exposure Reachability: Internal Require HTTPS for the credential-bearing Kaasu request. If a merchant enters an
|
||
| 'timeout' => 15, | ||
| 'headers' => [ | ||
| 'Authorization' => 'Bearer ' . $this->get_option('api_key'), | ||
| 'Content-Type' => 'application/json', | ||
| // Stable per order, so a retry never creates a second payment. | ||
| 'Idempotency-Key' => 'wc-' . $order_id . '-' . $order->get_order_key(), | ||
| ], | ||
| 'body' => wp_json_encode(['amount_paise' => $amount, 'reference' => (string) $order->get_order_number()]), | ||
| ]); | ||
| if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 201) { | ||
| wc_add_notice('Payment could not be started. Please try again.', 'error'); | ||
| return ['result' => 'failure']; | ||
| } | ||
| $data = json_decode(wp_remote_retrieve_body($response), true); | ||
| $order->update_meta_data('_kaasu_order_id', $data['id'] ?? ''); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔎 Supported by static analysis
Validate the Kaasu order ID before reporting payment startup as successful. If the 🐛 Suggested fix
|
||
| $order->save(); | ||
| return [ | ||
| 'result' => 'success', | ||
| 'redirect' => rtrim($this->get_option('kaasu_url'), '/') . '/pay/' . rawurlencode((string) ($data['id'] ?? '')), | ||
| ]; | ||
|
Comment on lines
+55
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | Clear the cart after a successful payment start. If order creation succeeds, this method redirects without clearing the WooCommerce cart. The shopper returns from the pay page to a cart that still contains the submitted items and can start another checkout. Clear the cart on this success path; do not clear it when order creation fails. WooCommerce’s gateway example clears the cart in
|
||
| } | ||
|
|
||
| public function handle_webhook(): void { | ||
| $body = file_get_contents('php://input'); | ||
| $sig = $_SERVER['HTTP_X_KAASU_SIGNATURE'] ?? ''; | ||
| if (!Kaasu_Signature::verify((string) $this->get_option('webhook_secret'), $sig, $body, time())) { | ||
| status_header(401); | ||
| exit; | ||
| } | ||
| $event = json_decode($body, true); | ||
| $event_id = (string) ($event['event_id'] ?? ''); | ||
| if ($event_id === '' || get_transient('kaasu_evt_' . $event_id)) { | ||
| status_header(200); | ||
| exit; | ||
| } | ||
| set_transient('kaasu_evt_' . $event_id, 1, DAY_IN_SECONDS); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | Record a webhook event only after the order update succeeds. If 🧰 Tools
|
||
| if (($event['type'] ?? '') === 'order.confirmed') { | ||
| $kaasu_id = (string) ($event['data']['order']['id'] ?? ''); | ||
| $order = $this->find_order($kaasu_id); | ||
| if ($order && !$order->is_paid()) { | ||
| $order->payment_complete($kaasu_id); | ||
| } | ||
| } | ||
| status_header(200); | ||
| exit; | ||
| } | ||
|
|
||
| private function find_order(string $kaasu_id): ?WC_Order { | ||
| $found = wc_get_orders(['meta_key' => '_kaasu_order_id', 'meta_value' => $kaasu_id, 'limit' => 1]); | ||
| return $found[0] ?? null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| <?php | ||
| if (!defined('ABSPATH')) { | ||
| exit; | ||
| } | ||
|
|
||
| /** | ||
| * Verifies X-Kaasu-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t "." body)>. | ||
| */ | ||
| final class Kaasu_Signature { | ||
| public static function verify(string $secret, string $header, string $body, int $now, int $tolerance = 300): bool { | ||
| $parts = []; | ||
| foreach (explode(',', $header) as $p) { | ||
| $kv = explode('=', $p, 2); | ||
| if (count($kv) === 2) { | ||
| $parts[$kv[0]] = $kv[1]; | ||
| } | ||
| } | ||
| if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { | ||
| return false; | ||
| } | ||
| $t = (int) $parts['t']; | ||
| if (abs($now - $t) > $tolerance) { | ||
| return false; | ||
| } | ||
| $want = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret); | ||
| return hash_equals($want, $parts['v1']); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| <?php | ||
| /** | ||
| * Plugin Name: Kaasu for WooCommerce | ||
| * Description: Accept UPI payments through a self-hosted Kaasu server. | ||
| * Version: 0.1.0 | ||
| * Requires PHP: 8.1 | ||
| * License: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| if (!defined('ABSPATH')) { | ||
| exit; | ||
| } | ||
|
|
||
| require_once __DIR__ . '/includes/class-kaasu-signature.php'; | ||
| require_once __DIR__ . '/includes/class-kaasu-amount.php'; | ||
| require_once __DIR__ . '/includes/class-kaasu-gateway.php'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | Load the gateway after WooCommerce is available. If WooCommerce is inactive or has not loaded when this plugin file runs, requiring 🧰 Tools
|
||
|
|
||
| add_filter('woocommerce_payment_gateways', function (array $gateways): array { | ||
| $gateways[] = 'Kaasu_Gateway'; | ||
|
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | Register a Checkout Block payment method. If a store uses the Checkout Block, registering
|
||
| return $gateways; | ||
| }); | ||
|
|
||
| add_action('before_woocommerce_init', function () { | ||
| if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) { | ||
| \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true); | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| === Kaasu for WooCommerce === | ||
| Requires PHP: 8.1 | ||
| License: AGPL-3.0-or-later | ||
|
|
||
| Accept UPI payments through your own self-hosted Kaasu server. Money goes straight from the shopper's bank to yours. Kaasu marks the order paid only after a signed "order.confirmed" webhook arrives. | ||
|
|
||
| == Setup == | ||
| 1. Set the Kaasu URL, API key and webhook secret in WooCommerce > Settings > Payments > Kaasu. | ||
| 2. In Kaasu, point the webhook at https://your-store.example/?wc-api=kaasu and use the same secret. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <?php | ||
| if (!defined('WP_UNINSTALL_PLUGIN')) { | ||
| exit; | ||
| } | ||
| delete_option('woocommerce_kaasu_settings'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <?php | ||
| // Plain PHP tests, no WordPress needed. Run: php integrations/woocommerce/tests/run.php | ||
| define('ABSPATH', __DIR__); | ||
| require __DIR__ . '/../kaasu-for-woocommerce/includes/class-kaasu-amount.php'; | ||
| require __DIR__ . '/../kaasu-for-woocommerce/includes/class-kaasu-signature.php'; | ||
|
|
||
| $failures = 0; | ||
| function expect($label, $got, $want) { | ||
| global $failures; | ||
| if ($got !== $want) { | ||
| $failures++; | ||
| echo "FAIL $label: got " . var_export($got, true) . " want " . var_export($want, true) . "\n"; | ||
| } | ||
| } | ||
|
|
||
| // Amounts: integer paise, never floats. | ||
| expect('499.07', Kaasu_Amount::to_paise('499.07'), 49907); | ||
| expect('0.29', Kaasu_Amount::to_paise('0.29'), 29); | ||
| expect('1200.00', Kaasu_Amount::to_paise('1200.00'), 120000); | ||
| expect('5', Kaasu_Amount::to_paise('5'), 500); | ||
| foreach (['', '-5', '1,200.00', '1.234', 'abc', '0', '0.00'] as $bad) { | ||
| try { Kaasu_Amount::to_paise($bad); expect("reject $bad", 'accepted', 'rejected'); } | ||
| catch (InvalidArgumentException $e) { } | ||
| } | ||
|
|
||
| // Signatures, the same vector the Go verifier uses. | ||
| $secret = 'whsec_test_example'; | ||
| $body = '{"event_id":"evt_example","type":"order.confirmed","data":{"order":{"id":"ord_1","state":"confirmed","amount_paise":49907}}}'; | ||
| $header = 't=1767261600,v1=' . hash_hmac('sha256', '1767261600.' . $body, $secret); | ||
| expect('genuine', Kaasu_Signature::verify($secret, $header, $body, 1767261600), true); | ||
| expect('tampered', Kaasu_Signature::verify($secret, $header, $body . ' ', 1767261600), false); | ||
| expect('stale', Kaasu_Signature::verify($secret, $header, $body, 1767261600 + 600), false); | ||
| expect('wrong secret', Kaasu_Signature::verify('other', $header, $body, 1767261600), false); | ||
| expect('garbage', Kaasu_Signature::verify($secret, 'nope', $body, 1767261600), false); | ||
|
|
||
| if ($failures) { exit(1); } | ||
| echo "all woocommerce checks pass\n"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability |
Minor | ⚡ Quick win
Make the bucket name unique across AWS accounts.
If another account owns
kaasu-${var.shop_id}-data, S3 rejects creation and prevents provisioning that shop. The validatedshop_iddoes not prevent this collision because bucket names use a shared namespace. Add a stable, generated suffix to the name. (docs.aws.amazon.com)🧰 Tools
🪛 Checkov (3.3.25)
[medium] 9-11: Ensure that an S3 bucket has a lifecycle configuration
(CKV2_AWS_61)